Skip to main content

rustdf/sim/
dia.rs

1use mscore::algorithm::isotope::{
2    calculate_precursor_transmission_factor,
3    calculate_transmission_dependent_fragment_ion_isotope_distribution,
4};
5use mscore::data::peptide::{PeptideIon, PeptideProductIonSeriesCollection};
6use mscore::data::spectrum::{IndexedMzSpectrum, MsType, MzSpectrum};
7use mscore::simulation::annotation::{
8    MzSpectrumAnnotated, PeakAnnotation, TimsFrameAnnotated, TimsSpectrumAnnotated,
9};
10use mscore::timstof::collision::{TimsTofCollisionEnergy, TimsTofCollisionEnergyDIA};
11use mscore::timstof::frame::TimsFrame;
12use mscore::timstof::quadrupole::{IonTransmission, TimsTransmissionDIA, WindowTransmission};
13use mscore::timstof::spectrum::TimsSpectrum;
14use std::collections::{BTreeMap, HashSet};
15use std::path::Path;
16use std::sync::Arc;
17
18use rand::Rng;
19use rayon::prelude::*;
20use rayon::ThreadPoolBuilder;
21
22use crate::sim::containers::{IsotopeTransmissionConfig, IsotopeTransmissionMode};
23use crate::sim::projector::{IntensityStage, MzCoordSpace, RenderedEvent, RenderedSpectrum};
24use crate::sim::scheme::{DataMode, InstrumentCapabilities, IsolationWindow};
25use crate::sim::handle::{FragmentIonsWithComplementary, TimsTofSyntheticsDataHandle};
26use crate::sim::precursor::TimsTofSyntheticsPrecursorFrameBuilder;
27
28/// Vendor-neutral per-fragment-series spectrum (P6a MS2 physics kernel): scale one
29/// fragment ion series into its `MzSpectrum` under the isotope-transmission mode,
30/// PRE m/z-noise. This is the fragment physics shared by every instrument — the
31/// transmission GATING (which scans/windows reach here) and the per-scan vs
32/// collapsed aggregation stay in the vendor adapter. `transmission_factor` is the
33/// precursor-scaling factor (PrecursorScaling), `frag_data` the per-(peptide,
34/// charge,CE) complementary data (PerFragment), `transmitted_indices` the
35/// quadrupole-transmitted precursor-isotope indices. Extracted verbatim from the
36/// inline DIA/DDA computation so rendered output is unchanged.
37#[allow(clippy::too_many_arguments)]
38pub(crate) fn fragment_series_spectrum(
39    mode: IsotopeTransmissionMode,
40    fragment_ion_series: &MzSpectrum,
41    series_idx: usize,
42    fraction_events: f32,
43    transmission_factor: f64,
44    frag_data: Option<&FragmentIonsWithComplementary>,
45    transmitted_indices: &HashSet<usize>,
46    max_isotopes: usize,
47) -> MzSpectrum {
48    match mode {
49        IsotopeTransmissionMode::None => fragment_ion_series.clone() * fraction_events as f64,
50        IsotopeTransmissionMode::PrecursorScaling => {
51            fragment_ion_series.clone() * (fraction_events as f64 * transmission_factor)
52        }
53        IsotopeTransmissionMode::PerFragment => {
54            if let Some(frag_data) = frag_data {
55                if series_idx < frag_data.per_fragment_data.len() {
56                    let series_data = &frag_data.per_fragment_data[series_idx];
57                    let mut aggregated_mz: Vec<f64> = Vec::new();
58                    let mut aggregated_intensity: Vec<f64> = Vec::new();
59                    for frag_ion_data in series_data {
60                        let adjusted_dist =
61                            calculate_transmission_dependent_fragment_ion_isotope_distribution(
62                                &frag_ion_data.fragment_distribution,
63                                &frag_ion_data.complementary_distribution,
64                                transmitted_indices,
65                                max_isotopes,
66                            );
67                        for (mz, abundance) in adjusted_dist {
68                            aggregated_mz.push(mz);
69                            aggregated_intensity
70                                .push(abundance * frag_ion_data.predicted_intensity * fraction_events as f64);
71                        }
72                    }
73                    if !aggregated_mz.is_empty() {
74                        MzSpectrum::new(aggregated_mz, aggregated_intensity)
75                    } else {
76                        fragment_ion_series.clone() * fraction_events as f64
77                    }
78                } else {
79                    fragment_ion_series.clone() * fraction_events as f64
80                }
81            } else {
82                fragment_ion_series.clone() * fraction_events as f64
83            }
84        }
85    }
86}
87
88pub struct TimsTofSyntheticsFrameBuilderDIA {
89    pub path: String,
90    pub precursor_frame_builder: TimsTofSyntheticsPrecursorFrameBuilder,
91    pub transmission_settings: TimsTransmissionDIA,
92    pub fragmentation_settings: TimsTofCollisionEnergyDIA,
93    pub fragment_ions:
94        Option<BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrum>)>>,
95    pub fragment_ions_annotated: Option<
96        BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrumAnnotated>)>,
97    >,
98    /// Configuration for quad-selection dependent isotope transmission (already
99    /// gated by the instrument capabilities at construction — P5e).
100    pub isotope_config: IsotopeTransmissionConfig,
101    /// Fragment ions with complementary data for transmission-dependent calculations
102    pub fragment_ions_with_transmission:
103        Option<BTreeMap<(u32, i8, i32), FragmentIonsWithComplementary>>,
104    /// Physical instrument capabilities (P5e). Default = Bruker timsTOF.
105    pub capabilities: InstrumentCapabilities,
106}
107
108impl TimsTofSyntheticsFrameBuilderDIA {
109    pub fn new(path: &Path, with_annotations: bool, num_threads: usize) -> rusqlite::Result<Self> {
110        Self::new_with_config(path, with_annotations, num_threads, IsotopeTransmissionConfig::default())
111    }
112
113    pub fn new_with_config(
114        path: &Path,
115        with_annotations: bool,
116        num_threads: usize,
117        isotope_config: IsotopeTransmissionConfig,
118    ) -> rusqlite::Result<Self> {
119        Self::new_with_config_and_source(
120            path,
121            with_annotations,
122            num_threads,
123            isotope_config,
124            &crate::sim::projector::DistributionSource::Columns,
125        )
126    }
127
128    /// As [`new_with_config`], but the precursor builder's occurrence/abundance
129    /// distributions come from `source` (P4: `Columns` default, or the projector).
130    pub fn new_with_config_and_source(
131        path: &Path,
132        with_annotations: bool,
133        num_threads: usize,
134        isotope_config: IsotopeTransmissionConfig,
135        source: &crate::sim::projector::DistributionSource,
136    ) -> rusqlite::Result<Self> {
137        let synthetics = TimsTofSyntheticsPrecursorFrameBuilder::from_source(path, source)?;
138        let handle = TimsTofSyntheticsDataHandle::new(path)?;
139
140        // P5b: refuse to render fragments stored under an incompatible prediction
141        // set (CE encoding the render keying can't resolve). Bruker/legacy pass.
142        handle
143            .read_prediction_set()?
144            .assert_render_compatible()
145            .map_err(|_| rusqlite::Error::InvalidQuery)?;
146
147        let fragment_ions = handle.read_fragment_ions()?;
148
149        // P5e: gate the isotope-transmission config by instrument capabilities.
150        // Default = Bruker timsTOF (no-op); P6 threads real Astral capabilities.
151        let capabilities = InstrumentCapabilities::default();
152        let isotope_config = isotope_config.gated_by(capabilities);
153
154        // get collision energy settings per window group
155        let fragmentation_settings = handle.get_collision_energy_dia();
156        // get ion transmission settings per window group
157        let transmission_settings = handle.get_transmission_dia();
158
159        // Build transmission data if isotope transmission is enabled
160        let fragment_ions_with_transmission = if isotope_config.is_enabled() {
161            Some(TimsTofSyntheticsDataHandle::build_fragment_ions_with_transmission_data(
162                &synthetics.peptides,
163                &fragment_ions,
164                num_threads,
165            ))
166        } else {
167            None
168        };
169
170        match with_annotations {
171            true => {
172                let fragment_ions_annotated =
173                    Some(TimsTofSyntheticsDataHandle::build_fragment_ions_annotated(
174                        &synthetics.peptides,
175                        &fragment_ions,
176                        num_threads,
177                    ));
178                Ok(Self {
179                    path: path.to_str().unwrap().to_string(),
180                    precursor_frame_builder: synthetics,
181                    transmission_settings,
182                    fragmentation_settings,
183                    fragment_ions: None,
184                    fragment_ions_annotated,
185                    isotope_config,
186                    fragment_ions_with_transmission,
187                    capabilities,
188                })
189            }
190
191            false => {
192                let fragment_ions = Some(TimsTofSyntheticsDataHandle::build_fragment_ions(
193                    &synthetics.peptides,
194                    &fragment_ions,
195                    num_threads,
196                ));
197                Ok(Self {
198                    path: path.to_str().unwrap().to_string(),
199                    precursor_frame_builder: synthetics,
200                    transmission_settings,
201                    fragmentation_settings,
202                    fragment_ions,
203                    fragment_ions_annotated: None,
204                    isotope_config,
205                    fragment_ions_with_transmission,
206                    capabilities,
207                })
208            }
209        }
210    }
211
212    /// Build a frame for DIA synthetic experiment
213    ///
214    /// # Arguments
215    ///
216    /// * `frame_id` - The frame id
217    /// * `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
218    ///
219    /// # Returns
220    ///
221    /// A TimsFrame
222    ///
223    pub fn build_frame(
224        &self,
225        frame_id: u32,
226        fragmentation: bool,
227        mz_noise_precursor: bool,
228        uniform: bool,
229        precursor_noise_ppm: f64,
230        mz_noise_fragment: bool,
231        fragment_noise_ppm: f64,
232        right_drag: bool,
233    ) -> TimsFrame {
234        // determine if the frame is a precursor frame
235        match self
236            .precursor_frame_builder
237            .precursor_frame_id_set
238            .contains(&frame_id)
239        {
240            true => self.build_ms1_frame(
241                frame_id,
242                mz_noise_precursor,
243                uniform,
244                precursor_noise_ppm,
245                right_drag,
246            ),
247            false => self.build_ms2_frame(
248                frame_id,
249                fragmentation,
250                mz_noise_fragment,
251                uniform,
252                fragment_noise_ppm,
253                right_drag,
254            ),
255        }
256    }
257
258    pub fn build_frame_annotated(
259        &self,
260        frame_id: u32,
261        fragmentation: bool,
262        mz_noise_precursor: bool,
263        uniform: bool,
264        precursor_noise_ppm: f64,
265        mz_noise_fragment: bool,
266        fragment_noise_ppm: f64,
267        right_drag: bool,
268    ) -> TimsFrameAnnotated {
269        match self
270            .precursor_frame_builder
271            .precursor_frame_id_set
272            .contains(&frame_id)
273        {
274            true => self.build_ms1_frame_annotated(
275                frame_id,
276                mz_noise_precursor,
277                uniform,
278                precursor_noise_ppm,
279                right_drag,
280            ),
281            false => self.build_ms2_frame_annotated(
282                frame_id,
283                fragmentation,
284                mz_noise_fragment,
285                uniform,
286                fragment_noise_ppm,
287                right_drag,
288            ),
289        }
290    }
291
292    pub fn get_fragment_ion_ids(&self, precursor_frame_ids: Vec<u32>) -> Vec<u32> {
293        let mut peptide_ids: HashSet<u32> = HashSet::new();
294        // get all peptide ids for the precursor frame ids
295        for frame_id in precursor_frame_ids {
296            for (peptide_id, peptide) in self.precursor_frame_builder.peptides.iter() {
297                if peptide.frame_start <= frame_id && peptide.frame_end >= frame_id {
298                    peptide_ids.insert(*peptide_id);
299                }
300            }
301        }
302        // get all ion ids for the peptide ids
303        let mut result: Vec<u32> = Vec::new();
304        for item in peptide_ids {
305            let ions = self.precursor_frame_builder.ions.get(&item).unwrap();
306            for ion in ions.iter() {
307                result.push(ion.ion_id);
308            }
309        }
310        result
311    }
312
313    pub fn build_frames(
314        &self,
315        frame_ids: Vec<u32>,
316        fragmentation: bool,
317        mz_noise_precursor: bool,
318        uniform: bool,
319        precursor_noise_ppm: f64,
320        mz_noise_fragment: bool,
321        fragment_noise_ppm: f64,
322        right_drag: bool,
323        num_threads: usize,
324    ) -> Vec<TimsFrame> {
325        // Use global thread pool with custom parallelism instead of creating new pool each call
326        let pool = rayon::ThreadPoolBuilder::new()
327            .num_threads(num_threads)
328            .build()
329            .unwrap();
330
331        pool.install(|| {
332            // Use indexed parallel iteration to maintain order, avoiding post-sort
333            let mut tims_frames: Vec<TimsFrame> = Vec::with_capacity(frame_ids.len());
334            // Safety: we're about to fill all elements
335            unsafe { tims_frames.set_len(frame_ids.len()); }
336
337            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
338                let frame = self.build_frame(
339                    *frame_id,
340                    fragmentation,
341                    mz_noise_precursor,
342                    uniform,
343                    precursor_noise_ppm,
344                    mz_noise_fragment,
345                    fragment_noise_ppm,
346                    right_drag,
347                );
348                // Safety: each index is unique due to enumerate
349                unsafe {
350                    let ptr = tims_frames.as_ptr() as *mut TimsFrame;
351                    std::ptr::write(ptr.add(idx), frame);
352                }
353            });
354
355            tims_frames
356        })
357    }
358
359    pub fn build_frames_annotated(
360        &self,
361        frame_ids: Vec<u32>,
362        fragmentation: bool,
363        mz_noise_precursor: bool,
364        uniform: bool,
365        precursor_noise_ppm: f64,
366        mz_noise_fragment: bool,
367        fragment_noise_ppm: f64,
368        right_drag: bool,
369        num_threads: usize,
370    ) -> Vec<TimsFrameAnnotated> {
371        // Use thread pool with custom parallelism
372        let pool = rayon::ThreadPoolBuilder::new()
373            .num_threads(num_threads)
374            .build()
375            .unwrap();
376
377        pool.install(|| {
378            // Use indexed parallel iteration to maintain order, avoiding post-sort
379            let mut tims_frames: Vec<TimsFrameAnnotated> = Vec::with_capacity(frame_ids.len());
380            unsafe { tims_frames.set_len(frame_ids.len()); }
381
382            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
383                let frame = self.build_frame_annotated(
384                    *frame_id,
385                    fragmentation,
386                    mz_noise_precursor,
387                    uniform,
388                    precursor_noise_ppm,
389                    mz_noise_fragment,
390                    fragment_noise_ppm,
391                    right_drag,
392                );
393                unsafe {
394                    let ptr = tims_frames.as_ptr() as *mut TimsFrameAnnotated;
395                    std::ptr::write(ptr.add(idx), frame);
396                }
397            });
398
399            tims_frames
400        })
401    }
402
403    fn build_ms1_frame(
404        &self,
405        frame_id: u32,
406        mz_noise_precursor: bool,
407        uniform: bool,
408        precursor_ppm: f64,
409        right_drag: bool,
410    ) -> TimsFrame {
411        let mut tims_frame = self.precursor_frame_builder.build_precursor_frame(
412            frame_id,
413            mz_noise_precursor,
414            uniform,
415            precursor_ppm,
416            right_drag,
417        );
418        let intensities_rounded = tims_frame
419            .ims_frame
420            .intensity
421            .iter()
422            .map(|x| x.round())
423            .collect::<Vec<_>>();
424        tims_frame.ims_frame.intensity = Arc::new(intensities_rounded);
425        tims_frame
426    }
427
428    fn build_ms1_frame_annotated(
429        &self,
430        frame_id: u32,
431        mz_noise_precursor: bool,
432        uniform: bool,
433        precursor_ppm: f64,
434        right_drag: bool,
435    ) -> TimsFrameAnnotated {
436        let mut tims_frame = self
437            .precursor_frame_builder
438            .build_precursor_frame_annotated(
439                frame_id,
440                mz_noise_precursor,
441                uniform,
442                precursor_ppm,
443                right_drag,
444            );
445        let intensities_rounded = tims_frame
446            .intensity
447            .iter()
448            .map(|x| x.round())
449            .collect::<Vec<_>>();
450        tims_frame.intensity = intensities_rounded;
451        tims_frame
452    }
453
454    fn build_ms2_frame(
455        &self,
456        frame_id: u32,
457        fragmentation: bool,
458        mz_noise_fragment: bool,
459        uniform: bool,
460        fragment_ppm: f64,
461        right_drag: bool,
462    ) -> TimsFrame {
463        match fragmentation {
464            false => {
465                let mut frame = self.transmission_settings.transmit_tims_frame(
466                    &self.build_ms1_frame(
467                        frame_id,
468                        mz_noise_fragment,
469                        uniform,
470                        fragment_ppm,
471                        right_drag,
472                    ),
473                    None,
474                );
475                let intensities_rounded = frame
476                    .ims_frame
477                    .intensity
478                    .iter()
479                    .map(|x| x.round())
480                    .collect::<Vec<_>>();
481                frame.ims_frame.intensity = Arc::new(intensities_rounded);
482                frame.ms_type = MsType::FragmentDia;
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                frame.ms_type = MsType::FragmentDia;
537                frame
538            }
539            true => {
540                let mut frame = self.build_fragment_frame_annotated(
541                    frame_id,
542                    &self.fragment_ions_annotated.as_ref().unwrap(),
543                    mz_noise_fragment,
544                    uniform,
545                    fragment_ppm,
546                    None,
547                    None,
548                    None,
549                    Some(right_drag),
550                );
551                let intensities_rounded = frame
552                    .intensity
553                    .iter()
554                    .map(|x| x.round())
555                    .collect::<Vec<_>>();
556                frame.intensity = intensities_rounded;
557                frame
558            }
559        }
560    }
561
562    /// Build a fragment frame
563    ///
564    /// # Arguments
565    ///
566    /// * `frame_id` - The frame id
567    /// * `mz_min` - The minimum m/z value in fragment spectrum
568    /// * `mz_max` - The maximum m/z value in fragment spectrum
569    /// * `intensity_min` - The minimum intensity value in fragment spectrum
570    ///
571    /// # Returns
572    ///
573    /// A TimsFrame
574    ///
575    fn build_fragment_frame(
576        &self,
577        frame_id: u32,
578        fragment_ions: &BTreeMap<
579            (u32, i8, i32),
580            (PeptideProductIonSeriesCollection, Vec<MzSpectrum>),
581        >,
582        mz_noise_fragment: bool,
583        uniform: bool,
584        fragment_ppm: f64,
585        mz_min: Option<f64>,
586        mz_max: Option<f64>,
587        intensity_min: Option<f64>,
588        right_drag: Option<bool>,
589    ) -> TimsFrame {
590        // Cache frame-level lookups once
591        let ms_type = if self.precursor_frame_builder.precursor_frame_id_set.contains(&frame_id) {
592            MsType::Unknown
593        } else {
594            MsType::FragmentDia
595        };
596
597        let rt = *self.precursor_frame_builder.frame_to_rt.get(&frame_id).unwrap() as f64;
598        let right_drag_val = right_drag.unwrap_or(false);
599        let mz_min_val = mz_min.unwrap_or(100.0);
600        let mz_max_val = mz_max.unwrap_or(1700.0);
601        let intensity_min_val = intensity_min.unwrap_or(1.0);
602
603        // Use single lookup instead of contains_key + get
604        let Some((peptide_ids, frame_abundances)) = self
605            .precursor_frame_builder
606            .frame_to_abundances
607            .get(&frame_id)
608        else {
609            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
610        };
611
612        // Preallocate with estimated capacity
613        let estimated_capacity = peptide_ids.len() * 4;
614        let mut tims_spectra: Vec<TimsSpectrum> = Vec::with_capacity(estimated_capacity);
615
616        // Go over all peptides in the frame with their respective abundances
617        for (peptide_id, frame_abundance) in peptide_ids.iter().zip(frame_abundances.iter()) {
618            // Single lookup instead of contains_key + get
619            let Some((ion_abundances, scan_occurrences, scan_abundances, charges, spectra)) = self
620                .precursor_frame_builder
621                .peptide_to_ions
622                .get(peptide_id)
623            else {
624                continue;
625            };
626
627            // Cache peptide-level lookup
628            let total_events = *self.precursor_frame_builder.peptide_to_events.get(peptide_id).unwrap();
629
630            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
631                let all_scan_occurrence = &scan_occurrences[index];
632                let all_scan_abundance = &scan_abundances[index];
633                let spectrum = &spectra[index];
634                let charge_state = charges[index];
635
636                for (scan, scan_abundance) in all_scan_occurrence.iter().zip(all_scan_abundance.iter()) {
637                    // Get transmitted isotope indices based on config mode
638                    let (is_transmitted, transmitted_indices) = match self.isotope_config.mode {
639                        IsotopeTransmissionMode::None => {
640                            // Standard check without indices
641                            let any = self.transmission_settings.any_transmitted(
642                                frame_id as i32,
643                                *scan as i32,
644                                &spectrum.mz,
645                                None,
646                            );
647                            (any, HashSet::new())
648                        },
649                        IsotopeTransmissionMode::PrecursorScaling | IsotopeTransmissionMode::PerFragment => {
650                            let indices = self.transmission_settings.get_transmission_set(
651                                frame_id as i32,
652                                *scan as i32,
653                                &spectrum.mz,
654                                Some(self.isotope_config.min_probability),
655                            );
656                            (!indices.is_empty(), indices)
657                        },
658                    };
659
660                    if !is_transmitted {
661                        continue;
662                    }
663
664                    // Calculate abundance factor (total_events cached above)
665                    let fraction_events = frame_abundance * scan_abundance * ion_abundance * total_events;
666
667                    // Get collision energy for the ion
668                    let collision_energy = self.fragmentation_settings.get_collision_energy(frame_id as i32, *scan as i32);
669                    // Resolve the fragment CE key, tolerant to ~0.1 eV quantization
670                    // noise (no-op for DIA's pre-rounded window CE; shared with DDA).
671                    let Some(collision_energy_quantized) = crate::sim::handle::resolve_fragment_ce_key(
672                        fragment_ions, *peptide_id, charge_state, collision_energy,
673                    ) else {
674                        // Fail loud if fragments exist for this ion but none near the
675                        // applied CE (prediction set doesn't cover this CE, P5b); a
676                        // precursor with no predicted fragments at all is a legit skip.
677                        if crate::sim::handle::fragment_prefix_exists(fragment_ions, *peptide_id, charge_state) {
678                            panic!(
679                                "DIA fragment lookup miss: peptide {} charge {} applied CE {:.4} eV \
680                                 has predicted fragments, but none within 0.1 eV — the prediction \
681                                 set does not cover this instrument's collision energy",
682                                *peptide_id, charge_state, collision_energy,
683                            );
684                        }
685                        continue;
686                    };
687                    let (_, fragment_series_vec) = fragment_ions
688                        .get(&(*peptide_id, charge_state, collision_energy_quantized))
689                        .expect("resolve_fragment_ce_key returned a present key");
690
691                    // Cache scan mobility lookup
692                    let scan_mobility = *self.precursor_frame_builder.scan_to_mobility.get(scan).unwrap() as f64;
693
694                    // Calculate transmission factor for PrecursorScaling mode
695                    let transmission_factor = if self.isotope_config.mode == IsotopeTransmissionMode::PrecursorScaling {
696                        if let Some(comp_data) = self.fragment_ions_with_transmission.as_ref() {
697                            if let Some(frag_data) = comp_data.get(&(*peptide_id, charge_state, collision_energy_quantized)) {
698                                calculate_precursor_transmission_factor(
699                                    &frag_data.precursor_isotope_distribution,
700                                    &transmitted_indices,
701                                )
702                            } else {
703                                1.0
704                            }
705                        } else {
706                            1.0
707                        }
708                    } else {
709                        1.0
710                    };
711
712                    // Complementary per-(peptide,charge,CE) data for PerFragment;
713                    // looked up once (same for all series) and passed to the kernel.
714                    let frag_data = self
715                        .fragment_ions_with_transmission
716                        .as_ref()
717                        .and_then(|c| c.get(&(*peptide_id, charge_state, collision_energy_quantized)));
718
719                    for (series_idx, fragment_ion_series) in fragment_series_vec.iter().enumerate() {
720                        let final_spectrum = fragment_series_spectrum(
721                            self.isotope_config.mode,
722                            fragment_ion_series,
723                            series_idx,
724                            fraction_events,
725                            transmission_factor,
726                            frag_data,
727                            &transmitted_indices,
728                            self.isotope_config.max_isotopes,
729                        );
730
731                        let mz_spectrum = if mz_noise_fragment {
732                            if uniform {
733                                final_spectrum.add_mz_noise_uniform(fragment_ppm, right_drag_val)
734                            } else {
735                                final_spectrum.add_mz_noise_normal(fragment_ppm)
736                            }
737                        } else {
738                            final_spectrum
739                        };
740
741                        let spectrum_len = mz_spectrum.mz.len();
742                        tims_spectra.push(TimsSpectrum::new(
743                            frame_id as i32,
744                            *scan as i32,
745                            rt,
746                            scan_mobility,
747                            ms_type.clone(),
748                            IndexedMzSpectrum::from_mz_spectrum(
749                                vec![0; spectrum_len],
750                                mz_spectrum,
751                            ).filter_ranged(100.0, 1700.0, 1.0, 1e9),
752                        ));
753                    }
754
755                    // Add unfragmented precursor ions (survival) if configured
756                    if self.isotope_config.has_precursor_survival() {
757                        let mut rng = rand::thread_rng();
758                        let survival_fraction = rng.gen_range(
759                            self.isotope_config.precursor_survival_min
760                            ..=self.isotope_config.precursor_survival_max
761                        );
762
763                        if survival_fraction > 0.0 {
764                            // Transmit the precursor spectrum through the quadrupole
765                            let precursor_transmitted = self.transmission_settings.transmit_spectrum(
766                                frame_id as i32,
767                                *scan as i32,
768                                spectrum.clone(),
769                                Some(self.isotope_config.min_probability),
770                            );
771
772                            if !precursor_transmitted.mz.is_empty() {
773                                // Scale by survival fraction and event count
774                                let precursor_scaled = precursor_transmitted * (fraction_events as f64 * survival_fraction);
775
776                                let precursor_mz_spectrum = if mz_noise_fragment {
777                                    if uniform {
778                                        precursor_scaled.add_mz_noise_uniform(fragment_ppm, right_drag_val)
779                                    } else {
780                                        precursor_scaled.add_mz_noise_normal(fragment_ppm)
781                                    }
782                                } else {
783                                    precursor_scaled
784                                };
785
786                                let precursor_len = precursor_mz_spectrum.mz.len();
787                                tims_spectra.push(TimsSpectrum::new(
788                                    frame_id as i32,
789                                    *scan as i32,
790                                    rt,
791                                    scan_mobility,
792                                    ms_type.clone(),
793                                    IndexedMzSpectrum::from_mz_spectrum(
794                                        vec![0; precursor_len],
795                                        precursor_mz_spectrum,
796                                    ).filter_ranged(100.0, 1700.0, 1.0, 1e9),
797                                ));
798                            }
799                        }
800                    }
801                }
802            }
803        }
804
805        if tims_spectra.is_empty() {
806            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
807        }
808
809        let tims_frame = TimsFrame::from_tims_spectra(tims_spectra);
810        tims_frame.filter_ranged(
811            mz_min_val,
812            mz_max_val,
813            0,
814            1000,
815            0.0,
816            10.0,
817            intensity_min_val,
818            1e9,
819            0,
820            i32::MAX,
821        )
822    }
823
824    /// Render a fragment (MS2) frame as a vendor-neutral [`RenderedEvent::Scan`]
825    /// for a non-IMS instrument (P6e): collapse ONE isolation window's fragment
826    /// signal into the single MS2 spectrum an Astral/Orbitrap records.
827    ///
828    /// Astral has no ion-mobility axis, so (a) each precursor ion is gated into the
829    /// window ONCE via [`WindowTransmission::any_transmitted`] on its isotope
830    /// envelope (an m/z window, NOT a scan range), and (b) every transmitted ion
831    /// contributes its fragment series at the FULL mobility marginal
832    /// `frame_abundance × ion_abundance × total_events` (scan factor folded to 1.0)
833    /// — the same marginal contract as the MS1 render
834    /// ([`TimsTofSyntheticsPrecursorFrameBuilder::precursor_scan_marginal_spectrum`]),
835    /// NOT the Bruker per-scan `scan_abundance` sum. The isotope-transmission mode
836    /// is `None` (Astral capabilities gate it off), so the shared
837    /// [`fragment_series_spectrum`] kernel does the per-series scaling. `nce` is the
838    /// window's normalized collision energy: both the CE key the stored fragments
839    /// were predicted at AND the applied CE (the keying is unit-agnostic). A
840    /// precursor whose fragments are not stored near `nce` is SKIPPED (predicted at
841    /// a different window's CE) — compatibility is enforced once at registration
842    /// (P6d), so the render core never panics on a per-precursor miss. Returns an
843    /// empty-spectrum MS2 `Scan` when nothing is transmitted / no fragments exist
844    /// — the writer must still consume and clear that template slot (zero residual).
845    ///
846    /// Fragments ONLY: precursor-survival signal is intentionally not modelled here
847    /// (it is stochastic, which would break this deterministic render), and an
848    /// Astral run that configures `precursor_survival_*` is rejected at config load
849    /// rather than silently dropping it.
850    pub fn render_fragment_scan(
851        &self,
852        frame_id: u32,
853        window: &WindowTransmission,
854        nce: f64,
855        data_mode: DataMode,
856    ) -> RenderedEvent {
857        let rt = *self
858            .precursor_frame_builder
859            .frame_to_rt
860            .get(&frame_id)
861            .unwrap_or(&0.0) as f64;
862        let isolation = Some(IsolationWindow {
863            center_mz: window.center_mz,
864            width_mz: window.width_mz,
865        });
866        let make_scan = |peaks: MzSpectrum| RenderedEvent::Scan {
867            ms_level: 2,
868            retention_time_s: rt,
869            isolation,
870            spectrum: RenderedSpectrum {
871                mz: (*peaks.mz).clone(),
872                intensity: (*peaks.intensity).clone(),
873                coords: MzCoordSpace::Physical,
874                mode: data_mode,
875                detector_applied: false,
876                stage: IntensityStage::Transmitted,
877            },
878        };
879
880        let (Some(fragment_ions), Some((peptide_ids, frame_abundances))) = (
881            self.fragment_ions.as_ref(),
882            self.precursor_frame_builder.frame_to_abundances.get(&frame_id),
883        ) else {
884            return make_scan(MzSpectrum::from_collection(vec![]));
885        };
886
887        let mut specs: Vec<MzSpectrum> = Vec::new();
888        for (peptide_id, frame_abundance) in peptide_ids.iter().zip(frame_abundances.iter()) {
889            let Some((ion_abundances, _scan_occ, _scan_abu, charges, spectra)) =
890                self.precursor_frame_builder.peptide_to_ions.get(peptide_id)
891            else {
892                continue;
893            };
894            let total_events = *self
895                .precursor_frame_builder
896                .peptide_to_events
897                .get(peptide_id)
898                .unwrap();
899            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
900                let spectrum = &spectra[index];
901                let charge_state = charges[index];
902                // Quadrupole window gating (m/z), once per ion — no IMS scans.
903                if !window.any_transmitted(&spectrum.mz, None) {
904                    continue;
905                }
906                // Resolve the fragment CE key at the window NCE (unit-agnostic).
907                // A miss SKIPS this precursor for this window — unlike the Bruker
908                // same-instrument frame builder (which panics on a coverage gap),
909                // the Astral render gates precursors by m/z ALONE (no mobility), so
910                // a precursor whose fragments were predicted at a different window's
911                // CE legitimately does not contribute here. Prediction-set/instrument
912                // compatibility is enforced once, up front, at registration (P6d).
913                let Some(ce_key) = crate::sim::handle::resolve_fragment_ce_key(
914                    fragment_ions,
915                    *peptide_id,
916                    charge_state,
917                    nce,
918                ) else {
919                    continue;
920                };
921                let (_, fragment_series_vec) = fragment_ions
922                    .get(&(*peptide_id, charge_state, ce_key))
923                    .expect("resolve_fragment_ce_key returned a present key");
924
925                // Full mobility marginal: scan factor folded to 1.0 (no grid).
926                let fraction_events = frame_abundance * ion_abundance * total_events;
927                for (series_idx, fragment_ion_series) in fragment_series_vec.iter().enumerate() {
928                    specs.push(fragment_series_spectrum(
929                        IsotopeTransmissionMode::None,
930                        fragment_ion_series,
931                        series_idx,
932                        fraction_events,
933                        1.0,
934                        None,
935                        &HashSet::new(),
936                        self.isotope_config.max_isotopes,
937                    ));
938                }
939            }
940        }
941        make_scan(MzSpectrum::from_collection(specs))
942    }
943
944    pub fn build_fragment_frame_annotated(
945        &self,
946        frame_id: u32,
947        fragment_ions: &BTreeMap<
948            (u32, i8, i32),
949            (PeptideProductIonSeriesCollection, Vec<MzSpectrumAnnotated>),
950        >,
951        mz_noise_fragment: bool,
952        uniform: bool,
953        fragment_ppm: f64,
954        mz_min: Option<f64>,
955        mz_max: Option<f64>,
956        intensity_min: Option<f64>,
957        right_drag: Option<bool>,
958    ) -> TimsFrameAnnotated {
959        // Cache frame-level lookups
960        let ms_type = if self.precursor_frame_builder.precursor_frame_id_set.contains(&frame_id) {
961            MsType::Unknown
962        } else {
963            MsType::FragmentDia
964        };
965
966        let rt = *self.precursor_frame_builder.frame_to_rt.get(&frame_id).unwrap() as f64;
967        let right_drag_val = right_drag.unwrap_or(false);
968        let mz_min_val = mz_min.unwrap_or(100.0);
969        let mz_max_val = mz_max.unwrap_or(1700.0);
970        let intensity_min_val = intensity_min.unwrap_or(1.0);
971
972        // Single lookup instead of contains_key + get
973        let Some((peptide_ids, frame_abundances)) = self
974            .precursor_frame_builder
975            .frame_to_abundances
976            .get(&frame_id)
977        else {
978            return TimsFrameAnnotated::new(frame_id as i32, rt, ms_type, vec![], vec![], vec![], vec![], vec![], vec![]);
979        };
980
981        // Preallocate with estimated capacity
982        let estimated_capacity = peptide_ids.len() * 4;
983        let mut tims_spectra: Vec<TimsSpectrumAnnotated> = Vec::with_capacity(estimated_capacity);
984
985        for (peptide_id, frame_abundance) in peptide_ids.iter().zip(frame_abundances.iter()) {
986            // Single lookup
987            let Some((ion_abundances, scan_occurrences, scan_abundances, charges, _)) = self
988                .precursor_frame_builder
989                .peptide_to_ions
990                .get(peptide_id)
991            else {
992                continue;
993            };
994
995            // Cache peptide-level lookups
996            let total_events = *self.precursor_frame_builder.peptide_to_events.get(peptide_id).unwrap();
997            let peptide = self.precursor_frame_builder.peptides.get(peptide_id).unwrap();
998
999            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
1000                let all_scan_occurrence = &scan_occurrences[index];
1001                let all_scan_abundance = &scan_abundances[index];
1002                let charge_state = charges[index];
1003
1004                let ion = PeptideIon::new(
1005                    peptide.sequence.sequence.clone(),
1006                    charge_state as i32,
1007                    *ion_abundance as f64,
1008                    Some(*peptide_id as i32),
1009                );
1010                // TODO: make this configurable
1011                let spectrum = ion.calculate_isotopic_spectrum_annotated(1e-3, 1e-8, 200, 1e-4);
1012
1013                for (scan, scan_abundance) in all_scan_occurrence.iter().zip(all_scan_abundance.iter()) {
1014                    if !self.transmission_settings.any_transmitted(
1015                        frame_id as i32,
1016                        *scan as i32,
1017                        &spectrum.mz,
1018                        None,
1019                    ) {
1020                        continue;
1021                    }
1022
1023                    let fraction_events = frame_abundance * scan_abundance * ion_abundance * total_events;
1024
1025                    let collision_energy = self.fragmentation_settings.get_collision_energy(frame_id as i32, *scan as i32);
1026                    // Resolve the fragment CE key, tolerant to ~0.1 eV quantization noise.
1027                    let Some(collision_energy_quantized) = crate::sim::handle::resolve_fragment_ce_key(
1028                        fragment_ions, *peptide_id, charge_state, collision_energy,
1029                    ) else {
1030                        if crate::sim::handle::fragment_prefix_exists(fragment_ions, *peptide_id, charge_state) {
1031                            panic!(
1032                                "DIA (annotated) fragment lookup miss: peptide {} charge {} applied CE \
1033                                 {:.4} eV has predicted fragments, but none within 0.1 eV — the \
1034                                 prediction set does not cover this instrument's collision energy",
1035                                *peptide_id, charge_state, collision_energy,
1036                            );
1037                        }
1038                        continue;
1039                    };
1040                    let (_, fragment_series_vec) = fragment_ions
1041                        .get(&(*peptide_id, charge_state, collision_energy_quantized))
1042                        .expect("resolve_fragment_ce_key returned a present key");
1043
1044                    // Cache scan mobility
1045                    let scan_mobility = *self.precursor_frame_builder.scan_to_mobility.get(scan).unwrap() as f64;
1046
1047                    for fragment_ion_series in fragment_series_vec.iter() {
1048                        let scaled_spec = fragment_ion_series.clone() * fraction_events as f64;
1049
1050                        let mz_spectrum = if mz_noise_fragment {
1051                            if uniform {
1052                                scaled_spec.add_mz_noise_uniform(fragment_ppm, right_drag_val)
1053                            } else {
1054                                scaled_spec.add_mz_noise_normal(fragment_ppm)
1055                            }
1056                        } else {
1057                            scaled_spec
1058                        };
1059
1060                        let spectrum_len = mz_spectrum.mz.len();
1061                        tims_spectra.push(TimsSpectrumAnnotated::new(
1062                            frame_id as i32,
1063                            *scan,
1064                            rt,
1065                            scan_mobility,
1066                            ms_type.clone(),
1067                            vec![0; spectrum_len],
1068                            mz_spectrum,
1069                        ));
1070                    }
1071
1072                    // Add unfragmented precursor ions (survival) if configured
1073                    if self.isotope_config.has_precursor_survival() {
1074                        let mut rng = rand::thread_rng();
1075                        let survival_fraction = rng.gen_range(
1076                            self.isotope_config.precursor_survival_min
1077                            ..=self.isotope_config.precursor_survival_max
1078                        );
1079
1080                        if survival_fraction > 0.0 {
1081                            // Create a non-annotated spectrum for transmission
1082                            let precursor_mz_spectrum = MzSpectrum::new(spectrum.mz.clone(), spectrum.intensity.clone());
1083
1084                            // Transmit through the quadrupole
1085                            let precursor_transmitted = self.transmission_settings.transmit_spectrum(
1086                                frame_id as i32,
1087                                *scan as i32,
1088                                precursor_mz_spectrum,
1089                                Some(self.isotope_config.min_probability),
1090                            );
1091
1092                            if !precursor_transmitted.mz.is_empty() {
1093                                // Scale by survival fraction and event count
1094                                let precursor_scaled = precursor_transmitted * (fraction_events as f64 * survival_fraction);
1095
1096                                let precursor_final = if mz_noise_fragment {
1097                                    if uniform {
1098                                        precursor_scaled.add_mz_noise_uniform(fragment_ppm, right_drag_val)
1099                                    } else {
1100                                        precursor_scaled.add_mz_noise_normal(fragment_ppm)
1101                                    }
1102                                } else {
1103                                    precursor_scaled
1104                                };
1105
1106                                // Convert to annotated spectrum (with precursor annotations)
1107                                let annotations: Vec<PeakAnnotation> = precursor_final.mz.iter()
1108                                    .map(|_| PeakAnnotation { contributions: vec![] })
1109                                    .collect();
1110                                let precursor_annotated = MzSpectrumAnnotated::new(
1111                                    precursor_final.mz.to_vec(),
1112                                    precursor_final.intensity.to_vec(),
1113                                    annotations,
1114                                );
1115
1116                                let precursor_len = precursor_annotated.mz.len();
1117                                tims_spectra.push(TimsSpectrumAnnotated::new(
1118                                    frame_id as i32,
1119                                    *scan,
1120                                    rt,
1121                                    scan_mobility,
1122                                    ms_type.clone(),
1123                                    vec![0; precursor_len],
1124                                    precursor_annotated,
1125                                ));
1126                            }
1127                        }
1128                    }
1129                }
1130            }
1131        }
1132
1133        if tims_spectra.is_empty() {
1134            return TimsFrameAnnotated::new(frame_id as i32, rt, ms_type, vec![], vec![], vec![], vec![], vec![], vec![]);
1135        }
1136
1137        TimsFrameAnnotated::from_tims_spectra_annotated(tims_spectra).filter_ranged(
1138            mz_min_val, mz_max_val, 0.0, 10.0, 0, 1000, intensity_min_val, 1e9,
1139        )
1140    }
1141
1142    pub fn get_ion_transmission_matrix(
1143        &self,
1144        peptide_id: u32,
1145        charge: i8,
1146        include_precursor_frames: bool,
1147    ) -> Vec<Vec<f32>> {
1148        let maybe_peptide_sim = self.precursor_frame_builder.peptides.get(&peptide_id);
1149
1150        let mut frame_ids = match maybe_peptide_sim {
1151            Some(maybe_peptide_sim) => maybe_peptide_sim.frame_distribution.occurrence.clone(),
1152            _ => vec![],
1153        };
1154
1155        if !include_precursor_frames {
1156            frame_ids = frame_ids
1157                .iter()
1158                .filter(|frame_id| {
1159                    !self
1160                        .precursor_frame_builder
1161                        .precursor_frame_id_set
1162                        .contains(frame_id)
1163                })
1164                .cloned()
1165                .collect();
1166        }
1167
1168        let ion = self
1169            .precursor_frame_builder
1170            .ions
1171            .get(&peptide_id)
1172            .unwrap()
1173            .iter()
1174            .find(|ion| ion.charge == charge)
1175            .unwrap();
1176        let spectrum = ion.simulated_spectrum.clone();
1177        let scan_distribution = &ion.scan_distribution;
1178
1179        let mut transmission_matrix =
1180            vec![vec![0.0; frame_ids.len()]; scan_distribution.occurrence.len()];
1181
1182        for (frame_index, frame) in frame_ids.iter().enumerate() {
1183            for (scan_index, scan) in scan_distribution.occurrence.iter().enumerate() {
1184                if self.transmission_settings.all_transmitted(
1185                    *frame as i32,
1186                    *scan as i32,
1187                    &spectrum.mz,
1188                    None,
1189                ) {
1190                    transmission_matrix[scan_index][frame_index] = 1.0;
1191                } else if self.transmission_settings.any_transmitted(
1192                    *frame as i32,
1193                    *scan as i32,
1194                    &spectrum.mz,
1195                    None,
1196                ) {
1197                    let transmitted_spectrum = self.transmission_settings.transmit_spectrum(
1198                        *frame as i32,
1199                        *scan as i32,
1200                        spectrum.clone(),
1201                        None,
1202                    );
1203                    let percentage_transmitted = transmitted_spectrum.intensity.iter().sum::<f64>()
1204                        / spectrum.intensity.iter().sum::<f64>();
1205                    transmission_matrix[scan_index][frame_index] = percentage_transmitted as f32;
1206                }
1207            }
1208        }
1209
1210        transmission_matrix
1211    }
1212
1213    pub fn count_number_transmissions(&self, peptide_id: u32, charge: i8) -> (usize, usize) {
1214        let frame_ids: Vec<_> = self
1215            .precursor_frame_builder
1216            .peptides
1217            .get(&peptide_id)
1218            .unwrap()
1219            .frame_distribution
1220            .occurrence
1221            .clone()
1222            .iter()
1223            .filter(|frame_id| {
1224                !self
1225                    .precursor_frame_builder
1226                    .precursor_frame_id_set
1227                    .contains(frame_id)
1228            })
1229            .cloned()
1230            .collect();
1231        let ion = self
1232            .precursor_frame_builder
1233            .ions
1234            .get(&peptide_id)
1235            .unwrap()
1236            .iter()
1237            .find(|ion| ion.charge == charge)
1238            .unwrap();
1239        let spectrum = ion.simulated_spectrum.clone();
1240        let scan_distribution = &ion.scan_distribution;
1241
1242        let mut frame_count = 0;
1243        let mut scan_count = 0;
1244
1245        for frame in frame_ids.iter() {
1246            let mut frame_transmitted = false;
1247            for scan in scan_distribution.occurrence.iter() {
1248                if self.transmission_settings.any_transmitted(
1249                    *frame as i32,
1250                    *scan as i32,
1251                    &spectrum.mz,
1252                    None,
1253                ) {
1254                    frame_transmitted = true;
1255                    scan_count += 1;
1256                }
1257            }
1258            if frame_transmitted {
1259                frame_count += 1;
1260            }
1261        }
1262
1263        (frame_count, scan_count)
1264    }
1265
1266    pub fn count_number_transmissions_parallel(
1267        &self,
1268        peptide_ids: Vec<u32>,
1269        charge: Vec<i8>,
1270        num_threads: usize,
1271    ) -> Vec<(usize, usize)> {
1272        let thread_pool = ThreadPoolBuilder::new()
1273            .num_threads(num_threads)
1274            .build()
1275            .unwrap();
1276        let result: Vec<(usize, usize)> = thread_pool.install(|| {
1277            peptide_ids
1278                .par_iter()
1279                .zip(charge.par_iter())
1280                .map(|(peptide_id, charge)| self.count_number_transmissions(*peptide_id, *charge))
1281                .collect()
1282        });
1283
1284        result
1285    }
1286}
1287
1288impl TimsTofCollisionEnergy for TimsTofSyntheticsFrameBuilderDIA {
1289    fn get_collision_energy(&self, frame_id: i32, scan_id: i32) -> f64 {
1290        self.fragmentation_settings
1291            .get_collision_energy(frame_id, scan_id)
1292    }
1293}
1294
1295#[cfg(test)]
1296mod p6a_fragment_kernel_tests {
1297    use super::*;
1298    use std::collections::HashSet;
1299
1300    // The scaling branches of the shared fragment-series kernel. (PerFragment is a
1301    // verbatim move of the inline computation; no DB simulated with quad-transmission
1302    // data is available to integration-test it, so it is covered by the byte-parity
1303    // gate on the None path + code review.)
1304    #[test]
1305    fn fragment_series_spectrum_scaling_branches() {
1306        let series = MzSpectrum::new(vec![200.0, 300.0], vec![10.0, 20.0]);
1307        let empty: HashSet<usize> = HashSet::new();
1308
1309        // None: scale by fraction_events only.
1310        let none = fragment_series_spectrum(
1311            IsotopeTransmissionMode::None, &series, 0, 2.0, 99.0 /*ignored*/, None, &empty, 10,
1312        );
1313        assert_eq!(*none.mz, vec![200.0, 300.0]);
1314        assert_eq!(*none.intensity, vec![20.0, 40.0]);
1315
1316        // PrecursorScaling: scale by fraction_events * transmission_factor.
1317        let ps = fragment_series_spectrum(
1318            IsotopeTransmissionMode::PrecursorScaling, &series, 0, 2.0, 0.5, None, &empty, 10,
1319        );
1320        assert_eq!(*ps.intensity, vec![10.0, 20.0]); // 10*(2*0.5), 20*(2*0.5)
1321
1322        // PerFragment with no complementary data falls back to None-style scaling.
1323        let pf = fragment_series_spectrum(
1324            IsotopeTransmissionMode::PerFragment, &series, 0, 2.0, 0.5, None, &empty, 10,
1325        );
1326        assert_eq!(*pf.intensity, vec![20.0, 40.0]);
1327    }
1328
1329    use crate::sim::precursor::TimsTofSyntheticsPrecursorFrameBuilder;
1330
1331    /// Hand-built single-frame DIA builder: frame 2 holds peptide 10 (charge 2,
1332    /// precursor m/z 600) at frame_abundance 0.8, total_events 1000, captured on a
1333    /// scan grid Σ scan_abundance = 0.5. Its fragments (one b/y series, a single
1334    /// peak at m/z 200) are stored at NCE 27 (map key round(27*10)=270).
1335    fn one_fragment_frame_dia() -> TimsTofSyntheticsFrameBuilderDIA {
1336        let mut frame_to_abundances = BTreeMap::new();
1337        frame_to_abundances.insert(2u32, (vec![10u32], vec![0.8f32]));
1338        let mut peptide_to_ions = BTreeMap::new();
1339        peptide_to_ions.insert(
1340            10u32,
1341            (
1342                vec![1.0f32],
1343                vec![vec![100u32, 101u32]],
1344                vec![vec![0.3f32, 0.2f32]], // Σ = 0.5 captured (ignored by the marginal)
1345                vec![2i8],
1346                vec![MzSpectrum::new(vec![600.0], vec![1.0])], // precursor envelope
1347            ),
1348        );
1349        let mut peptide_to_events = BTreeMap::new();
1350        peptide_to_events.insert(10u32, 1000.0f32);
1351        let mut frame_to_rt = BTreeMap::new();
1352        frame_to_rt.insert(2u32, 30.0f32);
1353
1354        let precursor = TimsTofSyntheticsPrecursorFrameBuilder {
1355            ions: BTreeMap::new(),
1356            peptides: BTreeMap::new(),
1357            scans: Vec::new(),
1358            frames: Vec::new(),
1359            precursor_frame_id_set: HashSet::new(),
1360            frame_to_abundances,
1361            peptide_to_ions,
1362            frame_to_rt,
1363            scan_to_mobility: BTreeMap::new(),
1364            peptide_to_events,
1365            ion_id_to_peptide_charge: BTreeMap::new(),
1366        };
1367
1368        let mut fragment_ions = BTreeMap::new();
1369        fragment_ions.insert(
1370            (10u32, 2i8, 270i32), // NCE 27 -> round(27*10)
1371            (
1372                PeptideProductIonSeriesCollection::new(vec![]),
1373                vec![MzSpectrum::new(vec![200.0], vec![1.0])],
1374            ),
1375        );
1376
1377        TimsTofSyntheticsFrameBuilderDIA {
1378            path: String::new(),
1379            precursor_frame_builder: precursor,
1380            transmission_settings: TimsTransmissionDIA::new(
1381                vec![], vec![], vec![], vec![], vec![], vec![], vec![], None,
1382            ),
1383            fragmentation_settings: TimsTofCollisionEnergyDIA::new(
1384                vec![], vec![], vec![], vec![], vec![], vec![],
1385            ),
1386            fragment_ions: Some(fragment_ions),
1387            fragment_ions_annotated: None,
1388            isotope_config: IsotopeTransmissionConfig::default(),
1389            fragment_ions_with_transmission: None,
1390            capabilities: InstrumentCapabilities::astral(),
1391        }
1392    }
1393
1394    #[test]
1395    fn astral_ms2_render_full_marginal_and_window_gating() {
1396        let b = one_fragment_frame_dia();
1397
1398        // Window covering the precursor (m/z 600): the ion is transmitted once and
1399        // contributes its fragment series at the FULL mobility marginal
1400        // 0.8 * 1.0 * 1000 * 1.0 = 800 (NOT the captured Σ scan_abundance=0.5 -> 400).
1401        let win = WindowTransmission::new(600.0, 50.0, 15.0);
1402        let RenderedEvent::Scan { ms_level, isolation, spectrum, .. } =
1403            b.render_fragment_scan(2, &win, 27.0, DataMode::Centroid)
1404        else {
1405            panic!("MS2 render must be a Scan");
1406        };
1407        assert_eq!(ms_level, 2);
1408        let iso = isolation.expect("MS2 scan carries an isolation window");
1409        assert!((iso.center_mz - 600.0).abs() < 1e-9 && (iso.width_mz - 50.0).abs() < 1e-9);
1410        assert_eq!(spectrum.mz, vec![200.0]);
1411        assert!((spectrum.intensity[0] - 800.0).abs() < 1e-2, "full marginal, got {}", spectrum.intensity[0]);
1412
1413        // A window that does NOT cover the precursor transmits nothing -> empty scan
1414        // (the slot is still authored/cleared by the writer).
1415        let off = WindowTransmission::new(900.0, 50.0, 15.0);
1416        let RenderedEvent::Scan { spectrum: empty, .. } =
1417            b.render_fragment_scan(2, &off, 27.0, DataMode::Centroid)
1418        else {
1419            panic!("must be a Scan");
1420        };
1421        assert!(empty.mz.is_empty(), "no precursor transmitted -> empty MS2 scan");
1422
1423        // Determinism.
1424        let RenderedEvent::Scan { spectrum: s2, .. } =
1425            b.render_fragment_scan(2, &win, 27.0, DataMode::Centroid)
1426        else { panic!() };
1427        assert_eq!(s2.mz, spectrum.mz);
1428        assert_eq!(s2.intensity, spectrum.intensity);
1429    }
1430}