Skip to main content

rustdf/sim/
dda.rs

1use mscore::simulation::noise_rng::noise_rng;
2use mscore::algorithm::isotope::calculate_precursor_transmission_factor;
3use crate::sim::containers::IsotopeTransmissionMode;
4use mscore::data::peptide::{PeptideIon, PeptideProductIonSeriesCollection};
5use mscore::data::spectrum::{IndexedMzSpectrum, MsType, MzSpectrum};
6use mscore::simulation::annotation::{
7    MzSpectrumAnnotated, PeakAnnotation, TimsFrameAnnotated, TimsSpectrumAnnotated,
8};
9use mscore::timstof::frame::TimsFrame;
10use mscore::timstof::quadrupole::{IonTransmission, TimsTransmissionDDA};
11use mscore::timstof::spectrum::TimsSpectrum;
12use std::collections::{BTreeMap, HashSet};
13use std::path::Path;
14use std::sync::Arc;
15
16use rand::Rng;
17use rayon::prelude::*;
18use crate::sim::containers::IsotopeTransmissionConfig;
19use crate::sim::scheme::InstrumentCapabilities;
20use crate::sim::handle::{FragmentIonsWithComplementary, TimsTofSyntheticsDataHandle};
21use crate::sim::precursor::TimsTofSyntheticsPrecursorFrameBuilder;
22
23pub struct TimsTofSyntheticsFrameBuilderDDA {
24    pub path: String,
25    pub precursor_frame_builder: TimsTofSyntheticsPrecursorFrameBuilder,
26    pub transmission_settings: TimsTransmissionDDA,
27    pub fragment_ions:
28        Option<BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrum>)>>,
29    pub fragment_ions_annotated: Option<
30        BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrumAnnotated>)>,
31    >,
32    /// Configuration for quad-selection dependent isotope transmission (already
33    /// gated by the instrument capabilities at construction — P5e).
34    pub isotope_transmission_config: IsotopeTransmissionConfig,
35    /// Fragment ions with complementary distribution data (only populated when isotope_transmission_config.enabled)
36    pub fragment_ions_with_complementary: Option<BTreeMap<(u32, i8, i32), FragmentIonsWithComplementary>>,
37    /// Physical instrument capabilities (P5e). Default = Bruker timsTOF.
38    pub capabilities: InstrumentCapabilities,
39    /// Master seed for the simulation's noise. 0 keeps the legacy `thread_rng` behaviour
40    /// for API users that never set it; TimSim sets it from the run's `sample_seed`.
41    pub noise_seed: u64,
42}
43
44impl TimsTofSyntheticsFrameBuilderDDA {
45
46    /// Set the master seed for the m/z jitter, making a run's noise reproducible.
47    pub fn set_noise_seed(&mut self, seed: u64) {
48        self.noise_seed = seed;
49    }
50    /// Create a new DDA frame builder.
51    ///
52    /// # Arguments
53    ///
54    /// * `path` - Path to the simulation database
55    /// * `with_annotations` - Whether to include annotations
56    /// * `num_threads` - Number of threads for parallel processing
57    /// * `isotope_config` - Optional configuration for quad-dependent isotope transmission
58    pub fn new(
59        path: &Path,
60        with_annotations: bool,
61        num_threads: usize,
62        isotope_config: Option<IsotopeTransmissionConfig>,
63    ) -> Self {
64        Self::new_with_capabilities(
65            path,
66            with_annotations,
67            num_threads,
68            isotope_config,
69            InstrumentCapabilities::default(),
70        )
71    }
72
73    /// Like [`Self::new`], but with explicit instrument capabilities (P5e). The
74    /// isotope-transmission config is gated by them (an instrument without
75    /// mobility-dependent quad isotope transmission forces the mode to None).
76    /// Default capabilities = Bruker timsTOF, so [`Self::new`] is unchanged.
77    pub fn new_with_capabilities(
78        path: &Path,
79        with_annotations: bool,
80        num_threads: usize,
81        isotope_config: Option<IsotopeTransmissionConfig>,
82        capabilities: InstrumentCapabilities,
83    ) -> Self {
84        let handle = TimsTofSyntheticsDataHandle::new(path).unwrap();
85        // P5b: refuse to render fragments stored under an incompatible prediction
86        // set (e.g. a future Thermo set whose CE encoding the render keying can't
87        // resolve). Bruker/legacy sets pass; this never fires for current DBs.
88        handle
89            .read_prediction_set()
90            .expect("read prediction set")
91            .assert_render_compatible()
92            .expect("incompatible fragment prediction set for this renderer");
93        let fragment_ions_raw = handle.read_fragment_ions().unwrap();
94        let transmission_settings = handle.get_transmission_dda();
95
96        let synthetics = TimsTofSyntheticsPrecursorFrameBuilder::new(path).unwrap();
97        let config = isotope_config.unwrap_or_default().gated_by(capabilities);
98
99        // Build fragment ions with complementary data if any transmission mode is enabled
100        let fragment_ions_with_complementary = if config.is_enabled() {
101            Some(TimsTofSyntheticsDataHandle::build_fragment_ions_with_transmission_data(
102                &synthetics.peptides,
103                &fragment_ions_raw,
104                num_threads,
105            ))
106        } else {
107            None
108        };
109
110        match with_annotations {
111            true => {
112                let fragment_ions =
113                    Some(TimsTofSyntheticsDataHandle::build_fragment_ions_annotated(
114                        &synthetics.peptides,
115                        &fragment_ions_raw,
116                        num_threads,
117                    ));
118                Self {
119                    noise_seed: 0,
120                    path: path.to_str().unwrap().to_string(),
121                    precursor_frame_builder: synthetics,
122                    transmission_settings,
123                    fragment_ions: None,
124                    fragment_ions_annotated: fragment_ions,
125                    isotope_transmission_config: config,
126                    fragment_ions_with_complementary,
127                    capabilities,
128                }
129            }
130            false => {
131                let fragment_ions = Some(TimsTofSyntheticsDataHandle::build_fragment_ions(
132                    &synthetics.peptides,
133                    &fragment_ions_raw,
134                    num_threads,
135                ));
136                Self {
137                    noise_seed: 0,
138                    path: path.to_str().unwrap().to_string(),
139                    precursor_frame_builder: synthetics,
140                    transmission_settings,
141                    fragment_ions,
142                    fragment_ions_annotated: None,
143                    isotope_transmission_config: config,
144                    fragment_ions_with_complementary,
145                    capabilities,
146                }
147            }
148        }
149    }
150    /// Construct an eager DDA frame builder from already-loaded, in-memory
151    /// entities instead of reading the whole database.
152    ///
153    /// Used by the lazy DDA builder so it can delegate per-batch frame
154    /// construction to the *same* (correct) eager algorithm instead of keeping a
155    /// second, divergent copy. Only the non-annotated path is built (lazy loading
156    /// does not support annotations). `fragment_ions_raw` is the per-batch slice
157    /// of fragment ions for the supplied peptides, so memory stays bounded to the
158    /// batch.
159    pub fn from_entities(
160        precursor_frame_builder: TimsTofSyntheticsPrecursorFrameBuilder,
161        transmission_settings: TimsTransmissionDDA,
162        fragment_ions_raw: Vec<crate::sim::containers::FragmentIonSim>,
163        isotope_config: Option<IsotopeTransmissionConfig>,
164        fragmentation: bool,
165        num_threads: usize,
166    ) -> Self {
167        // Lazy DDA (delegates here) is Bruker timsTOF; gate is a no-op. P6 will
168        // thread real capabilities through the lazy path.
169        let config = isotope_config
170            .unwrap_or_default()
171            .gated_by(InstrumentCapabilities::default());
172
173        // The fragment isotope map is only consumed when fragmentation is on
174        // (build_ms2_frame's `true` branch). Expanding all predicted fragment
175        // intensities into isotope spectra is expensive, so skip it entirely in
176        // no-fragmentation mode (the `false` branch never touches these maps).
177        let (fragment_ions, fragment_ions_with_complementary) = if fragmentation {
178            let with_complementary = if config.is_enabled() {
179                Some(TimsTofSyntheticsDataHandle::build_fragment_ions_with_transmission_data(
180                    &precursor_frame_builder.peptides,
181                    &fragment_ions_raw,
182                    num_threads,
183                ))
184            } else {
185                None
186            };
187            let fragment_ions = Some(TimsTofSyntheticsDataHandle::build_fragment_ions(
188                &precursor_frame_builder.peptides,
189                &fragment_ions_raw,
190                num_threads,
191            ));
192            (fragment_ions, with_complementary)
193        } else {
194            (None, None)
195        };
196
197        Self {
198            noise_seed: 0,
199            path: String::new(),
200            precursor_frame_builder,
201            transmission_settings,
202            fragment_ions,
203            fragment_ions_annotated: None,
204            isotope_transmission_config: config,
205            fragment_ions_with_complementary,
206            capabilities: InstrumentCapabilities::default(),
207        }
208    }
209
210    /// Build a frame for DDA synthetic experiment
211    ///
212    /// # Arguments
213    ///
214    /// * `frame_id` - The frame id
215    /// * `fragmentation` - A boolean indicating if fragmentation is enabled, if false, the frame has same mz distribution as the precursor frame but will be quadrupole filtered
216    ///
217    /// # Returns
218    ///
219    /// A TimsFrame
220    ///
221    pub fn build_frame(
222        &self,
223        frame_id: u32,
224        fragmentation: bool,
225        mz_noise_precursor: bool,
226        uniform: bool,
227        precursor_noise_ppm: f64,
228        mz_noise_fragment: bool,
229        fragment_noise_ppm: f64,
230        right_drag: bool,
231    ) -> TimsFrame {
232        // determine if the frame is a precursor frame
233        match self
234            .precursor_frame_builder
235            .precursor_frame_id_set
236            .contains(&frame_id)
237        {
238            true => self.build_ms1_frame(
239                frame_id,
240                mz_noise_precursor,
241                uniform,
242                precursor_noise_ppm,
243                right_drag,
244            ),
245            false => self.build_ms2_frame(
246                frame_id,
247                fragmentation,
248                mz_noise_fragment,
249                uniform,
250                fragment_noise_ppm,
251                right_drag,
252            ),
253        }
254    }
255
256    pub fn build_frame_annotated(
257        &self,
258        frame_id: u32,
259        fragmentation: bool,
260        mz_noise_precursor: bool,
261        uniform: bool,
262        precursor_noise_ppm: f64,
263        mz_noise_fragment: bool,
264        fragment_noise_ppm: f64,
265        right_drag: bool,
266    ) -> TimsFrameAnnotated {
267        match self
268            .precursor_frame_builder
269            .precursor_frame_id_set
270            .contains(&frame_id)
271        {
272            true => self.build_ms1_frame_annotated(
273                frame_id,
274                mz_noise_precursor,
275                uniform,
276                precursor_noise_ppm,
277                right_drag,
278            ),
279            false => self.build_ms2_frame_annotated(
280                frame_id,
281                fragmentation,
282                mz_noise_fragment,
283                uniform,
284                fragment_noise_ppm,
285                right_drag,
286            ),
287        }
288    }
289
290    pub fn get_fragment_ion_ids(&self, precursor_frame_ids: Vec<u32>) -> Vec<u32> {
291        let mut peptide_ids: HashSet<u32> = HashSet::new();
292        // get all peptide ids for the precursor frame ids
293        for frame_id in precursor_frame_ids {
294            for (peptide_id, peptide) in self.precursor_frame_builder.peptides.iter() {
295                if peptide.frame_start <= frame_id && peptide.frame_end >= frame_id {
296                    peptide_ids.insert(*peptide_id);
297                }
298            }
299        }
300        // get all ion ids for the peptide ids
301        let mut result: Vec<u32> = Vec::new();
302        for item in peptide_ids {
303            let ions = self.precursor_frame_builder.ions.get(&item).unwrap();
304            for ion in ions.iter() {
305                result.push(ion.ion_id);
306            }
307        }
308        result
309    }
310
311    pub fn build_frames(
312        &self,
313        frame_ids: Vec<u32>,
314        fragmentation: bool,
315        mz_noise_precursor: bool,
316        uniform: bool,
317        precursor_noise_ppm: f64,
318        mz_noise_fragment: bool,
319        fragment_noise_ppm: f64,
320        right_drag: bool,
321        num_threads: usize,
322    ) -> Vec<TimsFrame> {
323        // Use thread pool with custom parallelism
324        let pool = rayon::ThreadPoolBuilder::new()
325            .num_threads(num_threads)
326            .build()
327            .unwrap();
328
329        pool.install(|| {
330            // Use indexed parallel iteration to maintain order, avoiding post-sort
331            let mut tims_frames: Vec<TimsFrame> = Vec::with_capacity(frame_ids.len());
332            unsafe { tims_frames.set_len(frame_ids.len()); }
333
334            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
335                let frame = self.build_frame(
336                    *frame_id,
337                    fragmentation,
338                    mz_noise_precursor,
339                    uniform,
340                    precursor_noise_ppm,
341                    mz_noise_fragment,
342                    fragment_noise_ppm,
343                    right_drag,
344                );
345                unsafe {
346                    let ptr = tims_frames.as_ptr() as *mut TimsFrame;
347                    std::ptr::write(ptr.add(idx), frame);
348                }
349            });
350
351            tims_frames
352        })
353    }
354    pub fn build_frames_annotated(
355        &self,
356        frame_ids: Vec<u32>,
357        fragmentation: bool,
358        mz_noise_precursor: bool,
359        uniform: bool,
360        precursor_noise_ppm: f64,
361        mz_noise_fragment: bool,
362        fragment_noise_ppm: f64,
363        right_drag: bool,
364        num_threads: usize,
365    ) -> Vec<TimsFrameAnnotated> {
366        // Use thread pool with custom parallelism
367        let pool = rayon::ThreadPoolBuilder::new()
368            .num_threads(num_threads)
369            .build()
370            .unwrap();
371
372        pool.install(|| {
373            // Use indexed parallel iteration to maintain order, avoiding post-sort
374            let mut tims_frames: Vec<TimsFrameAnnotated> = Vec::with_capacity(frame_ids.len());
375            unsafe { tims_frames.set_len(frame_ids.len()); }
376
377            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
378                let frame = self.build_frame_annotated(
379                    *frame_id,
380                    fragmentation,
381                    mz_noise_precursor,
382                    uniform,
383                    precursor_noise_ppm,
384                    mz_noise_fragment,
385                    fragment_noise_ppm,
386                    right_drag,
387                );
388                unsafe {
389                    let ptr = tims_frames.as_ptr() as *mut TimsFrameAnnotated;
390                    std::ptr::write(ptr.add(idx), frame);
391                }
392            });
393
394            tims_frames
395        })
396    }
397
398    fn build_ms1_frame(
399        &self,
400        frame_id: u32,
401        mz_noise_precursor: bool,
402        uniform: bool,
403        precursor_ppm: f64,
404        right_drag: bool,
405    ) -> TimsFrame {
406        let mut tims_frame = self.precursor_frame_builder.build_precursor_frame(
407            frame_id,
408            mz_noise_precursor,
409            uniform,
410            precursor_ppm,
411            right_drag,
412        );
413        let intensities_rounded = tims_frame
414            .ims_frame
415            .intensity
416            .iter()
417            .map(|x| x.round())
418            .collect::<Vec<_>>();
419        tims_frame.ims_frame.intensity = Arc::new(intensities_rounded);
420        tims_frame
421    }
422
423    fn build_ms1_frame_annotated(
424        &self,
425        frame_id: u32,
426        mz_noise_precursor: bool,
427        uniform: bool,
428        precursor_ppm: f64,
429        right_drag: bool,
430    ) -> TimsFrameAnnotated {
431        let mut tims_frame = self
432            .precursor_frame_builder
433            .build_precursor_frame_annotated(
434                frame_id,
435                mz_noise_precursor,
436                uniform,
437                precursor_ppm,
438                right_drag,
439            );
440        let intensities_rounded = tims_frame
441            .intensity
442            .iter()
443            .map(|x| x.round())
444            .collect::<Vec<_>>();
445        tims_frame.intensity = intensities_rounded;
446        tims_frame
447    }
448
449    fn build_ms2_frame(
450        &self,
451        frame_id: u32,
452        fragmentation: bool,
453        mz_noise_fragment: bool,
454        uniform: bool,
455        fragment_ppm: f64,
456        right_drag: bool,
457    ) -> TimsFrame {
458        match fragmentation {
459            false => {
460                let mut frame = self.transmission_settings.transmit_tims_frame(
461                    &self.build_ms1_frame(
462                        frame_id,
463                        mz_noise_fragment,
464                        uniform,
465                        fragment_ppm,
466                        right_drag,
467                    ),
468                    None,
469                );
470                let intensities_rounded = frame
471                    .ims_frame
472                    .intensity
473                    .iter()
474                    .map(|x| x.round())
475                    .collect::<Vec<_>>();
476                frame.ims_frame.intensity = Arc::new(intensities_rounded);
477                // DDA MS2 frames are PASEF fragment frames (MsMsType 8). The
478                // no-fragmentation mode still produces a DDA fragment frame
479                // (quad-filtered precursor), so it must keep the DDA type, not
480                // FragmentDia (9). The fragmentation=true branch already uses
481                // FragmentDda; this matches it.
482                frame.ms_type = MsType::FragmentDda;
483                frame
484            }
485            true => {
486                let mut frame = self.build_fragment_frame(
487                    frame_id,
488                    &self.fragment_ions.as_ref().unwrap(),
489                    mz_noise_fragment,
490                    uniform,
491                    fragment_ppm,
492                    None,
493                    None,
494                    None,
495                    Some(right_drag),
496                );
497                let intensities_rounded = frame
498                    .ims_frame
499                    .intensity
500                    .iter()
501                    .map(|x| x.round())
502                    .collect::<Vec<_>>();
503                frame.ims_frame.intensity = Arc::new(intensities_rounded);
504                frame
505            }
506        }
507    }
508
509    fn build_ms2_frame_annotated(
510        &self,
511        frame_id: u32,
512        fragmentation: bool,
513        mz_noise_fragment: bool,
514        uniform: bool,
515        fragment_ppm: f64,
516        right_drag: bool,
517    ) -> TimsFrameAnnotated {
518        match fragmentation {
519            false => {
520                let mut frame = self.transmission_settings.transmit_tims_frame_annotated(
521                    &self.build_ms1_frame_annotated(
522                        frame_id,
523                        mz_noise_fragment,
524                        uniform,
525                        fragment_ppm,
526                        right_drag,
527                    ),
528                    None,
529                );
530                let intensities_rounded = frame
531                    .intensity
532                    .iter()
533                    .map(|x| x.round())
534                    .collect::<Vec<_>>();
535                frame.intensity = intensities_rounded;
536                // See build_ms2_frame: DDA no-frag MS2 stays FragmentDda (8).
537                frame.ms_type = MsType::FragmentDda;
538                frame
539            }
540            true => {
541                let mut frame = self.build_fragment_frame_annotated(
542                    frame_id,
543                    &self.fragment_ions_annotated.as_ref().unwrap(),
544                    mz_noise_fragment,
545                    uniform,
546                    fragment_ppm,
547                    None,
548                    None,
549                    None,
550                    Some(right_drag),
551                );
552                let intensities_rounded = frame
553                    .intensity
554                    .iter()
555                    .map(|x| x.round())
556                    .collect::<Vec<_>>();
557                frame.intensity = intensities_rounded;
558                frame
559            }
560        }
561    }
562
563    /// Build a fragment frame
564    ///
565    /// # Arguments
566    ///
567    /// * `frame_id` - The frame id
568    /// * `mz_min` - The minimum m/z value in fragment spectrum
569    /// * `mz_max` - The maximum m/z value in fragment spectrum
570    /// * `intensity_min` - The minimum intensity value in fragment spectrum
571    ///
572    /// # Returns
573    ///
574    /// A TimsFrame
575    ///
576    fn build_fragment_frame(
577        &self,
578        frame_id: u32,
579        fragment_ions: &BTreeMap<
580            (u32, i8, i32),
581            (PeptideProductIonSeriesCollection, Vec<MzSpectrum>),
582        >,
583        mz_noise_fragment: bool,
584        uniform: bool,
585        fragment_ppm: f64,
586        mz_min: Option<f64>,
587        mz_max: Option<f64>,
588        intensity_min: Option<f64>,
589        right_drag: Option<bool>,
590    ) -> TimsFrame {
591        // One RNG per frame, keyed by the master seed and the frame id, so the m/z jitter
592        // does not depend on which thread builds the frame. See mscore::simulation::noise_rng.
593        let mut rng = noise_rng(self.noise_seed, &[frame_id as u64]);
594        // Cache frame-level lookups
595        let ms_type = if self.precursor_frame_builder.precursor_frame_id_set.contains(&frame_id) {
596            MsType::Unknown
597        } else {
598            MsType::FragmentDda
599        };
600
601        let rt = *self.precursor_frame_builder.frame_to_rt.get(&frame_id)
602            .expect("frame_to_rt should always have this frame") as f64;
603        let right_drag_val = right_drag.unwrap_or(false);
604        let mz_min_val = mz_min.unwrap_or(100.0);
605        let mz_max_val = mz_max.unwrap_or(1700.0);
606        let intensity_min_val = intensity_min.unwrap_or(1.0);
607
608        // Get PASEF meta for this frame - these define which precursors were SELECTED
609        let Some(pasef_meta) = self.transmission_settings.pasef_meta.get(&(frame_id as i32)) else {
610            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
611        };
612
613        // Preallocate with estimated capacity
614        let estimated_capacity = pasef_meta.len() * 4;
615        let mut tims_spectra: Vec<TimsSpectrum> = Vec::with_capacity(estimated_capacity);
616
617        // Iterate over PASEF meta entries - each represents a SELECTED precursor
618        for meta in pasef_meta.iter() {
619            // Get the selected ion_id from the PASEF precursor field
620            let ion_id = meta.precursor as u32;
621
622            // Look up peptide_id and charge from the ion_id
623            let Some(&(peptide_id, charge_state)) = self.precursor_frame_builder.ion_id_to_peptide_charge.get(&ion_id) else {
624                continue;
625            };
626
627            // Get peptide info
628            let Some(peptide) = self.precursor_frame_builder.peptides.get(&peptide_id) else {
629                continue;
630            };
631
632            // Check if this peptide is active in this frame
633            if frame_id < peptide.frame_start || frame_id > peptide.frame_end {
634                continue;
635            }
636
637            // Get ion info from peptide_to_ions
638            let Some((ion_abundances, scan_occurrences, scan_abundances, charges, spectra)) = self
639                .precursor_frame_builder
640                .peptide_to_ions
641                .get(&peptide_id)
642            else {
643                continue;
644            };
645
646            // Find the index of this specific charge state
647            let Some(ion_index) = charges.iter().position(|&c| c == charge_state) else {
648                continue;
649            };
650
651            let ion_abundance = ion_abundances[ion_index];
652            let all_scan_occurrence = &scan_occurrences[ion_index];
653            let all_scan_abundance = &scan_abundances[ion_index];
654            let spectrum = &spectra[ion_index];
655            let total_events = *self.precursor_frame_builder.peptide_to_events.get(&peptide_id).unwrap();
656
657            // Get frame abundance for this peptide
658            let frame_abundance = self.precursor_frame_builder.frame_to_abundances
659                .get(&frame_id)
660                .and_then(|(pep_ids, abundances)| {
661                    pep_ids.iter().position(|&p| p == peptide_id)
662                        .map(|idx| abundances[idx])
663                })
664                .unwrap_or(0.0);
665
666            if frame_abundance == 0.0 {
667                continue;
668            }
669
670            // Collision energy from the PASEF meta
671            let collision_energy = meta.collision_energy;
672            // Resolve the fragment CE key, tolerant to ~0.1 eV quantization noise
673            // (DDA pasef CE is full-precision; the stored key is f32-quantized).
674            let Some(collision_energy_quantized) = crate::sim::handle::resolve_fragment_ce_key(
675                fragment_ions, peptide_id, charge_state, collision_energy,
676            ) else {
677                // Fail loud if fragments exist for this precursor but none near the
678                // applied CE: the prediction set does not cover this instrument's CE
679                // (P5b). A precursor with NO predicted fragments at all is a
680                // legitimate skip. (For Bruker after the ±0.1 eV probe this never
681                // fires — verified 0 misses.)
682                if crate::sim::handle::fragment_prefix_exists(fragment_ions, peptide_id, charge_state) {
683                    panic!(
684                        "DDA fragment lookup miss: peptide {} charge {} applied CE {:.4} eV has \
685                         predicted fragments, but none within 0.1 eV — the prediction set does \
686                         not cover this instrument's collision energy",
687                        peptide_id, charge_state, collision_energy,
688                    );
689                }
690                continue;
691            };
692            let (_, fragment_series_vec) = fragment_ions
693                .get(&(peptide_id, charge_state, collision_energy_quantized))
694                .expect("resolve_fragment_ce_key returned a present key");
695
696            // Process scans within the PASEF selection window
697            for (scan, scan_abundance) in all_scan_occurrence.iter().zip(all_scan_abundance.iter()) {
698                // Check if scan is within the PASEF selection window
699                let scan_i32 = *scan as i32;
700                if scan_i32 < meta.scan_start || scan_i32 > meta.scan_end {
701                    continue;
702                }
703
704                // Get transmitted isotope indices based on config mode
705                let transmitted_indices = match self.isotope_transmission_config.mode {
706                    IsotopeTransmissionMode::None => HashSet::new(),
707                    IsotopeTransmissionMode::PrecursorScaling | IsotopeTransmissionMode::PerFragment => {
708                        self.transmission_settings.get_transmission_set(
709                            frame_id as i32,
710                            scan_i32,
711                            &spectrum.mz,
712                            Some(self.isotope_transmission_config.min_probability),
713                        )
714                    },
715                };
716
717                // Calculate abundance factor
718                let fraction_events = frame_abundance * scan_abundance * ion_abundance * total_events;
719
720                // Cache scan mobility
721                let scan_mobility = *self.precursor_frame_builder.scan_to_mobility.get(scan).unwrap() as f64;
722
723                // Calculate transmission factor for PrecursorScaling mode
724                let transmission_factor = if self.isotope_transmission_config.mode == IsotopeTransmissionMode::PrecursorScaling {
725                    if let Some(comp_data) = self.fragment_ions_with_complementary.as_ref() {
726                        if let Some(frag_data) = comp_data.get(&(peptide_id, charge_state, collision_energy_quantized)) {
727                            calculate_precursor_transmission_factor(
728                                &frag_data.precursor_isotope_distribution,
729                                &transmitted_indices,
730                            )
731                        } else {
732                            1.0
733                        }
734                    } else {
735                        1.0
736                    }
737                } else {
738                    1.0
739                };
740
741                // Complementary per-(peptide,charge,CE) data for PerFragment;
742                // looked up once (same for all series) and passed to the shared
743                // kernel — same lookup the transmission_factor block above uses.
744                let frag_data = self
745                    .fragment_ions_with_complementary
746                    .as_ref()
747                    .and_then(|c| c.get(&(peptide_id, charge_state, collision_energy_quantized)));
748
749                for (series_idx, fragment_ion_series) in fragment_series_vec.iter().enumerate() {
750                    let final_spectrum = crate::sim::dia::fragment_series_spectrum(
751                        self.isotope_transmission_config.mode,
752                        fragment_ion_series,
753                        series_idx,
754                        fraction_events,
755                        transmission_factor,
756                        frag_data,
757                        &transmitted_indices,
758                        self.isotope_transmission_config.max_isotopes,
759                    );
760
761                    let mz_spectrum = if mz_noise_fragment {
762                        if uniform {
763                            final_spectrum.add_mz_noise_uniform_with_rng(fragment_ppm, right_drag_val, &mut rng)
764                        } else {
765                            final_spectrum.add_mz_noise_normal_with_rng(fragment_ppm, &mut rng)
766                        }
767                    } else {
768                        final_spectrum
769                    };
770
771                    let spectrum_len = mz_spectrum.mz.len();
772                    tims_spectra.push(TimsSpectrum::new(
773                        frame_id as i32,
774                        *scan as i32,
775                        rt,
776                        scan_mobility,
777                        ms_type.clone(),
778                        IndexedMzSpectrum::from_mz_spectrum(
779                            vec![0; spectrum_len],
780                            mz_spectrum,
781                        ).filter_ranged(100.0, 1700.0, 1.0, 1e9),
782                    ));
783                }
784
785                // Add unfragmented precursor ions (survival) if configured
786                if self.isotope_transmission_config.has_precursor_survival() {
787                    let mut rng = rand::thread_rng();
788                    let survival_fraction = rng.gen_range(
789                        self.isotope_transmission_config.precursor_survival_min
790                        ..=self.isotope_transmission_config.precursor_survival_max
791                    );
792
793                    if survival_fraction > 0.0 {
794                        // Transmit the precursor spectrum through the quadrupole
795                        let precursor_transmitted = self.transmission_settings.transmit_spectrum(
796                            frame_id as i32,
797                            *scan as i32,
798                            spectrum.clone(),
799                            Some(self.isotope_transmission_config.min_probability),
800                        );
801
802                        if !precursor_transmitted.mz.is_empty() {
803                            // Scale by survival fraction and event count
804                            let precursor_scaled = precursor_transmitted * (fraction_events as f64 * survival_fraction);
805
806                            let precursor_mz_spectrum = if mz_noise_fragment {
807                                if uniform {
808                                    precursor_scaled.add_mz_noise_uniform_with_rng(fragment_ppm, right_drag_val, &mut rng)
809                                } else {
810                                    precursor_scaled.add_mz_noise_normal_with_rng(fragment_ppm, &mut rng)
811                                }
812                            } else {
813                                precursor_scaled
814                            };
815
816                            let precursor_len = precursor_mz_spectrum.mz.len();
817                            tims_spectra.push(TimsSpectrum::new(
818                                frame_id as i32,
819                                *scan as i32,
820                                rt,
821                                scan_mobility,
822                                ms_type.clone(),
823                                IndexedMzSpectrum::from_mz_spectrum(
824                                    vec![0; precursor_len],
825                                    precursor_mz_spectrum,
826                                ).filter_ranged(100.0, 1700.0, 1.0, 1e9),
827                            ));
828                        }
829                    }
830                }
831            }
832        }
833
834        if tims_spectra.is_empty() {
835            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
836        }
837
838        let tims_frame = TimsFrame::from_tims_spectra(tims_spectra);
839        tims_frame.filter_ranged(
840            mz_min_val,
841            mz_max_val,
842            0,
843            1000,
844            0.0,
845            10.0,
846            intensity_min_val,
847            1e9,
848            0,
849            i32::MAX,
850        )
851    }
852
853    pub fn build_fragment_frame_annotated(
854        &self,
855        frame_id: u32,
856        fragment_ions: &BTreeMap<
857            (u32, i8, i32),
858            (PeptideProductIonSeriesCollection, Vec<MzSpectrumAnnotated>),
859        >,
860        mz_noise_fragment: bool,
861        uniform: bool,
862        fragment_ppm: f64,
863        mz_min: Option<f64>,
864        mz_max: Option<f64>,
865        intensity_min: Option<f64>,
866        right_drag: Option<bool>,
867    ) -> TimsFrameAnnotated {
868        // One RNG per frame, keyed by the master seed and the frame id, so the m/z jitter
869        // does not depend on which thread builds the frame. See mscore::simulation::noise_rng.
870        let mut rng = noise_rng(self.noise_seed, &[frame_id as u64]);
871        // Cache frame-level lookups
872        let ms_type = if self.precursor_frame_builder.precursor_frame_id_set.contains(&frame_id) {
873            MsType::Unknown
874        } else {
875            MsType::FragmentDda
876        };
877
878        let rt = *self.precursor_frame_builder.frame_to_rt.get(&frame_id).unwrap() as f64;
879        let right_drag_val = right_drag.unwrap_or(false);
880        let mz_min_val = mz_min.unwrap_or(100.0);
881        let mz_max_val = mz_max.unwrap_or(1700.0);
882        let intensity_min_val = intensity_min.unwrap_or(1.0);
883
884        // Get PASEF meta for this frame - these define which precursors were SELECTED
885        let Some(pasef_meta) = self.transmission_settings.pasef_meta.get(&(frame_id as i32)) else {
886            return TimsFrameAnnotated::new(frame_id as i32, rt, ms_type, vec![], vec![], vec![], vec![], vec![], vec![]);
887        };
888
889        // Preallocate with estimated capacity
890        let estimated_capacity = pasef_meta.len() * 4;
891        let mut tims_spectra: Vec<TimsSpectrumAnnotated> = Vec::with_capacity(estimated_capacity);
892
893        // Iterate over PASEF meta entries - each represents a SELECTED precursor
894        for meta in pasef_meta.iter() {
895            // Get the selected ion_id from the PASEF precursor field
896            let ion_id = meta.precursor as u32;
897
898            // Look up peptide_id and charge from the ion_id
899            let Some(&(peptide_id, charge_state)) = self.precursor_frame_builder.ion_id_to_peptide_charge.get(&ion_id) else {
900                continue;
901            };
902
903            // Get peptide info
904            let Some(peptide) = self.precursor_frame_builder.peptides.get(&peptide_id) else {
905                continue;
906            };
907
908            // Check if this peptide is active in this frame
909            if frame_id < peptide.frame_start || frame_id > peptide.frame_end {
910                continue;
911            }
912
913            // Get ion info from peptide_to_ions
914            let Some((ion_abundances, scan_occurrences, scan_abundances, charges, _)) = self
915                .precursor_frame_builder
916                .peptide_to_ions
917                .get(&peptide_id)
918            else {
919                continue;
920            };
921
922            // Find the index of this specific charge state
923            let Some(ion_index) = charges.iter().position(|&c| c == charge_state) else {
924                continue;
925            };
926
927            let ion_abundance = ion_abundances[ion_index];
928            let all_scan_occurrence = &scan_occurrences[ion_index];
929            let all_scan_abundance = &scan_abundances[ion_index];
930            let total_events = *self.precursor_frame_builder.peptide_to_events.get(&peptide_id).unwrap();
931
932            // Get frame abundance for this peptide
933            let frame_abundance = self.precursor_frame_builder.frame_to_abundances
934                .get(&frame_id)
935                .and_then(|(pep_ids, abundances)| {
936                    pep_ids.iter().position(|&p| p == peptide_id)
937                        .map(|idx| abundances[idx])
938                })
939                .unwrap_or(0.0);
940
941            if frame_abundance == 0.0 {
942                continue;
943            }
944
945            // Collision energy from the PASEF meta
946            let collision_energy = meta.collision_energy;
947            // Resolve the fragment CE key, tolerant to ~0.1 eV quantization noise
948            // (DDA pasef CE is full-precision; the stored key is f32-quantized).
949            let Some(collision_energy_quantized) = crate::sim::handle::resolve_fragment_ce_key(
950                fragment_ions, peptide_id, charge_state, collision_energy,
951            ) else {
952                // Fail loud if fragments exist for this precursor but none near the
953                // applied CE: the prediction set does not cover this instrument's CE
954                // (P5b). A precursor with NO predicted fragments at all is a
955                // legitimate skip. (For Bruker after the ±0.1 eV probe this never
956                // fires — verified 0 misses.)
957                if crate::sim::handle::fragment_prefix_exists(fragment_ions, peptide_id, charge_state) {
958                    panic!(
959                        "DDA fragment lookup miss: peptide {} charge {} applied CE {:.4} eV has \
960                         predicted fragments, but none within 0.1 eV — the prediction set does \
961                         not cover this instrument's collision energy",
962                        peptide_id, charge_state, collision_energy,
963                    );
964                }
965                continue;
966            };
967            let (_, fragment_series_vec) = fragment_ions
968                .get(&(peptide_id, charge_state, collision_energy_quantized))
969                .expect("resolve_fragment_ce_key returned a present key");
970
971            // Create ion for annotation and precursor spectrum calculation
972            let ion = PeptideIon::new(
973                peptide.sequence.sequence.clone(),
974                charge_state as i32,
975                ion_abundance as f64,
976                Some(peptide_id as i32),
977            );
978            // Calculate isotopic spectrum for precursor survival
979            let precursor_spectrum_annotated = ion.calculate_isotopic_spectrum_annotated(1e-3, 1e-8, 200, 1e-4);
980
981            // Process scans within the PASEF selection window
982            for (scan, scan_abundance) in all_scan_occurrence.iter().zip(all_scan_abundance.iter()) {
983                // Check if scan is within the PASEF selection window
984                let scan_i32 = *scan as i32;
985                if scan_i32 < meta.scan_start || scan_i32 > meta.scan_end {
986                    continue;
987                }
988
989                // Calculate abundance factor
990                let fraction_events = frame_abundance * scan_abundance * ion_abundance * total_events;
991
992                // Cache scan mobility
993                let scan_mobility = *self.precursor_frame_builder.scan_to_mobility.get(scan).unwrap() as f64;
994
995                for fragment_ion_series in fragment_series_vec.iter() {
996                    let scaled_spec = fragment_ion_series.clone() * fraction_events as f64;
997
998                    let mz_spectrum = if mz_noise_fragment {
999                        if uniform {
1000                            scaled_spec.add_mz_noise_uniform_with_rng(fragment_ppm, right_drag_val, &mut rng)
1001                        } else {
1002                            scaled_spec.add_mz_noise_normal_with_rng(fragment_ppm, &mut rng)
1003                        }
1004                    } else {
1005                        scaled_spec
1006                    };
1007
1008                    let spectrum_len = mz_spectrum.mz.len();
1009                    tims_spectra.push(TimsSpectrumAnnotated::new(
1010                        frame_id as i32,
1011                        *scan,
1012                        rt,
1013                        scan_mobility,
1014                        ms_type.clone(),
1015                        vec![0; spectrum_len],
1016                        mz_spectrum,
1017                    ));
1018                }
1019
1020                // Add unfragmented precursor ions (survival) if configured
1021                if self.isotope_transmission_config.has_precursor_survival() {
1022                    let mut rng = rand::thread_rng();
1023                    let survival_fraction = rng.gen_range(
1024                        self.isotope_transmission_config.precursor_survival_min
1025                        ..=self.isotope_transmission_config.precursor_survival_max
1026                    );
1027
1028                    if survival_fraction > 0.0 {
1029                        // Create a non-annotated spectrum for transmission
1030                        let precursor_mz_spectrum = MzSpectrum::new(
1031                            precursor_spectrum_annotated.mz.clone(),
1032                            precursor_spectrum_annotated.intensity.clone(),
1033                        );
1034
1035                        // Transmit through the quadrupole
1036                        let precursor_transmitted = self.transmission_settings.transmit_spectrum(
1037                            frame_id as i32,
1038                            scan_i32,
1039                            precursor_mz_spectrum,
1040                            Some(self.isotope_transmission_config.min_probability),
1041                        );
1042
1043                        if !precursor_transmitted.mz.is_empty() {
1044                            // Scale by survival fraction and event count
1045                            let precursor_scaled = precursor_transmitted * (fraction_events as f64 * survival_fraction);
1046
1047                            let precursor_final = if mz_noise_fragment {
1048                                if uniform {
1049                                    precursor_scaled.add_mz_noise_uniform_with_rng(fragment_ppm, right_drag_val, &mut rng)
1050                                } else {
1051                                    precursor_scaled.add_mz_noise_normal_with_rng(fragment_ppm, &mut rng)
1052                                }
1053                            } else {
1054                                precursor_scaled
1055                            };
1056
1057                            // Convert to annotated spectrum (with precursor annotations)
1058                            let annotations: Vec<PeakAnnotation> = precursor_final.mz.iter()
1059                                .map(|_| PeakAnnotation { contributions: vec![] })
1060                                .collect();
1061                            let precursor_annotated = MzSpectrumAnnotated::new(
1062                                precursor_final.mz.to_vec(),
1063                                precursor_final.intensity.to_vec(),
1064                                annotations,
1065                            );
1066
1067                            let precursor_len = precursor_annotated.mz.len();
1068                            tims_spectra.push(TimsSpectrumAnnotated::new(
1069                                frame_id as i32,
1070                                *scan,
1071                                rt,
1072                                scan_mobility,
1073                                ms_type.clone(),
1074                                vec![0; precursor_len],
1075                                precursor_annotated,
1076                            ));
1077                        }
1078                    }
1079                }
1080            }
1081        }
1082
1083        if tims_spectra.is_empty() {
1084            return TimsFrameAnnotated::new(frame_id as i32, rt, ms_type, vec![], vec![], vec![], vec![], vec![], vec![]);
1085        }
1086
1087        TimsFrameAnnotated::from_tims_spectra_annotated(tims_spectra).filter_ranged(
1088            mz_min_val, mz_max_val, 0.0, 10.0, 0, 1000, intensity_min_val, 1e9,
1089        )
1090    }
1091
1092    pub fn get_collision_energy(&self, frame_id: i32, scan_id: i32) -> f64 {
1093        self.transmission_settings.get_collision_energy(frame_id, scan_id).unwrap_or(0.0)
1094    }
1095
1096    pub fn get_collision_energies(&self, frame_ids: Vec<i32>, scan_ids: Vec<i32>) -> Vec<f64> {
1097        let mut collision_energies: Vec<f64> = Vec::new();
1098        for frame_id in frame_ids {
1099            for scan_id in &scan_ids {
1100                collision_energies.push(self.get_collision_energy(frame_id, *scan_id));
1101            }
1102        }
1103        collision_energies
1104    }
1105}