Skip to main content

rustdf/sim/
lazy_builder.rs

1//! Lazy frame builders for DIA and DDA synthetic experiments.
2//!
3//! This module provides `TimsTofLazyFrameBuilderDIA` and `TimsTofLazyFrameBuilderDDA`,
4//! memory-efficient alternatives to their non-lazy counterparts that only load
5//! peptide/ion data for the frames being built rather than loading everything upfront.
6
7use mscore::simulation::noise_rng::noise_rng;
8use mscore::data::peptide::PeptideProductIonSeriesCollection;
9use mscore::data::spectrum::{IndexedMzSpectrum, MsType, MzSpectrum};
10use mscore::timstof::collision::{TimsTofCollisionEnergy, TimsTofCollisionEnergyDIA};
11use mscore::timstof::frame::TimsFrame;
12use mscore::timstof::quadrupole::{IonTransmission, TimsTransmissionDDA, TimsTransmissionDIA};
13use mscore::timstof::spectrum::TimsSpectrum;
14use std::collections::{BTreeMap, HashSet};
15use std::path::Path;
16use std::sync::Arc;
17
18use rayon::prelude::*;
19
20use crate::sim::containers::{FragmentIonSim, FramesSim, IonSim, PeptidesSim, ScansSim};
21use crate::sim::dda::TimsTofSyntheticsFrameBuilderDDA;
22use crate::sim::handle::TimsTofSyntheticsDataHandle;
23use crate::sim::precursor::TimsTofSyntheticsPrecursorFrameBuilder;
24use crate::sim::projector::DistributionSource;
25
26/// A lazy frame builder for DIA experiments that only loads data as needed.
27///
28/// Unlike `TimsTofSyntheticsFrameBuilderDIA`, this struct does not load all peptides,
29/// ions, and fragment ions into memory at construction time. Instead, it stores only
30/// the static metadata (frame info, scan info, transmission settings) and loads
31/// peptide/ion data on-demand for each batch of frames being built.
32///
33/// This can significantly reduce memory usage for large simulations.
34pub struct TimsTofLazyFrameBuilderDIA {
35    /// Path to the SQLite database
36    pub db_path: String,
37    /// Frame metadata (id, time, ms_type)
38    pub frames: Vec<FramesSim>,
39    /// Scan metadata (scan_id, mobility)
40    pub scans: Vec<ScansSim>,
41    /// Set of precursor frame IDs for quick lookup
42    pub precursor_frame_id_set: HashSet<u32>,
43    /// Map from frame_id to retention_time
44    pub frame_to_rt: BTreeMap<u32, f32>,
45    /// Map from scan_id to mobility
46    pub scan_to_mobility: BTreeMap<u32, f32>,
47    /// DIA transmission settings
48    pub transmission_settings: TimsTransmissionDIA,
49    /// DIA fragmentation/collision energy settings
50    pub fragmentation_settings: TimsTofCollisionEnergyDIA,
51    /// Number of threads for parallel processing
52    pub num_threads: usize,
53    /// Source for occurrence/abundance distributions (legacy columns or projector)
54    pub source: DistributionSource,
55    /// Master seed for the simulation's noise. 0 keeps the legacy `thread_rng` behaviour
56    /// for API users that never set it; TimSim sets it from the run's `sample_seed`.
57    pub noise_seed: u64,
58}
59
60impl TimsTofLazyFrameBuilderDIA {
61
62    /// Set the master seed for the m/z jitter, making a run's noise reproducible.
63    pub fn set_noise_seed(&mut self, seed: u64) {
64        self.noise_seed = seed;
65    }
66    /// Create a new lazy frame builder.
67    ///
68    /// Only loads static metadata (frames, scans, transmission settings).
69    /// Peptides, ions, and fragment ions are NOT loaded here.
70    ///
71    /// # Arguments
72    ///
73    /// * `path` - Path to the SQLite database
74    /// * `num_threads` - Number of threads for parallel operations
75    ///
76    /// # Returns
77    ///
78    /// Result containing the lazy frame builder
79    pub fn new(path: &Path, num_threads: usize) -> rusqlite::Result<Self> {
80        Self::new_with_source(path, num_threads, DistributionSource::Columns)
81    }
82
83    /// Like `new`, but reads occurrence/abundance distributions from `source`
84    /// (legacy columns by default, or the render-time projector).
85    pub fn new_with_source(
86        path: &Path,
87        num_threads: usize,
88        source: DistributionSource,
89    ) -> rusqlite::Result<Self> {
90        let handle = TimsTofSyntheticsDataHandle::new(path)?;
91        // P5b: refuse an incompatible fragment prediction set (Bruker/legacy pass).
92        handle
93            .read_prediction_set()?
94            .assert_render_compatible()
95            .map_err(|_| rusqlite::Error::InvalidQuery)?;
96
97        let frames = handle.read_frames()?;
98        let scans = handle.read_scans()?;
99
100        let precursor_frame_id_set = TimsTofSyntheticsDataHandle::build_precursor_frame_id_set(&frames);
101        let frame_to_rt = TimsTofSyntheticsDataHandle::build_frame_to_rt(&frames);
102        let scan_to_mobility = TimsTofSyntheticsDataHandle::build_scan_to_mobility(&scans);
103
104        let transmission_settings = handle.get_transmission_dia();
105        let fragmentation_settings = handle.get_collision_energy_dia();
106
107        Ok(Self {
108            noise_seed: 0,
109            db_path: path.to_str().unwrap().to_string(),
110            frames,
111            scans,
112            precursor_frame_id_set,
113            frame_to_rt,
114            scan_to_mobility,
115            transmission_settings,
116            fragmentation_settings,
117            num_threads,
118            source,
119        })
120    }
121
122    /// Load data for a specific frame range from the database.
123    ///
124    /// Returns peptides, ions, and fragment ions that are relevant to the frame range.
125    fn load_data_for_frame_range(
126        &self,
127        frame_min: u32,
128        frame_max: u32,
129    ) -> rusqlite::Result<(Vec<PeptidesSim>, Vec<IonSim>, Vec<FragmentIonSim>)> {
130        let path = Path::new(&self.db_path);
131        let handle = TimsTofSyntheticsDataHandle::new(path)?;
132
133        // Load only peptides for this frame range (source-aware: columns or projector)
134        let peptides = handle.read_peptides_for_frame_range_with_source(frame_min, frame_max, &self.source)?;
135
136        if peptides.is_empty() {
137            return Ok((Vec::new(), Vec::new(), Vec::new()));
138        }
139
140        // Get peptide IDs for querying related data
141        let peptide_ids: Vec<u32> = peptides.iter().map(|p| p.peptide_id).collect();
142
143        // Load ions and fragment ions for these peptides
144        let ions = handle.read_ions_for_peptides_with_source(&peptide_ids, &self.source)?;
145        let fragment_ions = handle.read_fragment_ions_for_peptides(&peptide_ids)?;
146
147        Ok((peptides, ions, fragment_ions))
148    }
149
150    /// Build frames for a range of frame IDs.
151    ///
152    /// This method loads only the data needed for the specified frames,
153    /// builds the frames, and then releases the loaded data.
154    ///
155    /// # Arguments
156    ///
157    /// * `frame_ids` - Vector of frame IDs to build
158    /// * `fragmentation` - Whether to include fragmentation
159    /// * `mz_noise_precursor` - Whether to add m/z noise to precursor ions
160    /// * `uniform` - Whether to use uniform noise distribution
161    /// * `precursor_noise_ppm` - Precursor noise in ppm
162    /// * `mz_noise_fragment` - Whether to add m/z noise to fragment ions
163    /// * `fragment_noise_ppm` - Fragment noise in ppm
164    /// * `right_drag` - Whether to use right drag for noise
165    ///
166    /// # Returns
167    ///
168    /// Vector of built TimsFrame instances
169    pub fn build_frames_lazy(
170        &self,
171        frame_ids: Vec<u32>,
172        fragmentation: bool,
173        mz_noise_precursor: bool,
174        uniform: bool,
175        precursor_noise_ppm: f64,
176        mz_noise_fragment: bool,
177        fragment_noise_ppm: f64,
178        right_drag: bool,
179    ) -> Vec<TimsFrame> {
180        if frame_ids.is_empty() {
181            return Vec::new();
182        }
183
184        // Determine frame range
185        let frame_min = *frame_ids.iter().min().unwrap();
186        let frame_max = *frame_ids.iter().max().unwrap();
187
188        // Load data for this frame range
189        let (peptides, ions, fragment_ions) = match self.load_data_for_frame_range(frame_min, frame_max) {
190            Ok(data) => data,
191            Err(_) => return Vec::new(),
192        };
193
194        // Build lookup maps
195        let peptide_map = TimsTofSyntheticsDataHandle::build_peptide_map(&peptides);
196        let peptide_to_ions = TimsTofSyntheticsDataHandle::build_peptide_to_ions(&ions);
197        let frame_to_abundances = TimsTofSyntheticsDataHandle::build_frame_to_abundances(&peptides);
198        let peptide_to_events = TimsTofSyntheticsDataHandle::build_peptide_to_events(&peptides);
199
200        // Build fragment ions map if fragmentation is enabled
201        let fragment_ions_map = if fragmentation {
202            Some(TimsTofSyntheticsDataHandle::build_fragment_ions(
203                &peptide_map,
204                &fragment_ions,
205                self.num_threads,
206            ))
207        } else {
208            None
209        };
210
211        // Build frames in parallel using indexed iteration to maintain order
212        let pool = rayon::ThreadPoolBuilder::new()
213            .num_threads(self.num_threads)
214            .build()
215            .unwrap();
216
217        pool.install(|| {
218            let mut tims_frames: Vec<TimsFrame> = Vec::with_capacity(frame_ids.len());
219            unsafe { tims_frames.set_len(frame_ids.len()); }
220
221            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
222                let frame = self.build_single_frame(
223                    *frame_id,
224                    fragmentation,
225                    mz_noise_precursor,
226                    uniform,
227                    precursor_noise_ppm,
228                    mz_noise_fragment,
229                    fragment_noise_ppm,
230                    right_drag,
231                    &peptide_map,
232                    &peptide_to_ions,
233                    &frame_to_abundances,
234                    &peptide_to_events,
235                    &fragment_ions_map,
236                );
237                unsafe {
238                    let ptr = tims_frames.as_ptr() as *mut TimsFrame;
239                    std::ptr::write(ptr.add(idx), frame);
240                }
241            });
242
243            tims_frames
244        })
245    }
246
247    /// Build a single frame with provided data maps.
248    #[allow(clippy::too_many_arguments)]
249    fn build_single_frame(
250        &self,
251        frame_id: u32,
252        fragmentation: bool,
253        mz_noise_precursor: bool,
254        uniform: bool,
255        precursor_noise_ppm: f64,
256        mz_noise_fragment: bool,
257        fragment_noise_ppm: f64,
258        right_drag: bool,
259        _peptide_map: &BTreeMap<u32, PeptidesSim>,
260        peptide_to_ions: &BTreeMap<u32, (Vec<f32>, Vec<Vec<u32>>, Vec<Vec<f32>>, Vec<i8>, Vec<MzSpectrum>)>,
261        frame_to_abundances: &BTreeMap<u32, (Vec<u32>, Vec<f32>)>,
262        peptide_to_events: &BTreeMap<u32, f32>,
263        fragment_ions_map: &Option<BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrum>)>>,
264    ) -> TimsFrame {
265        // Determine if this is a precursor or fragment frame
266        let is_precursor = self.precursor_frame_id_set.contains(&frame_id);
267
268        if is_precursor {
269            self.build_precursor_frame(
270                frame_id,
271                mz_noise_precursor,
272                uniform,
273                precursor_noise_ppm,
274                right_drag,
275                peptide_to_ions,
276                frame_to_abundances,
277                peptide_to_events,
278            )
279        } else {
280            self.build_fragment_frame(
281                frame_id,
282                fragmentation,
283                mz_noise_fragment,
284                uniform,
285                fragment_noise_ppm,
286                right_drag,
287                peptide_to_ions,
288                frame_to_abundances,
289                peptide_to_events,
290                fragment_ions_map,
291            )
292        }
293    }
294
295    /// Build a precursor (MS1) frame.
296    #[allow(clippy::too_many_arguments)]
297    fn build_precursor_frame(
298        &self,
299        frame_id: u32,
300        mz_noise_precursor: bool,
301        uniform: bool,
302        precursor_noise_ppm: f64,
303        right_drag: bool,
304        peptide_to_ions: &BTreeMap<u32, (Vec<f32>, Vec<Vec<u32>>, Vec<Vec<f32>>, Vec<i8>, Vec<MzSpectrum>)>,
305        frame_to_abundances: &BTreeMap<u32, (Vec<u32>, Vec<f32>)>,
306        peptide_to_events: &BTreeMap<u32, f32>,
307    ) -> TimsFrame {
308        // One RNG per frame, keyed by the master seed and the frame id, so the m/z jitter
309        // does not depend on which thread builds the frame. See mscore::simulation::noise_rng.
310        let mut rng = noise_rng(self.noise_seed, &[frame_id as u64]);
311        let ms_type = MsType::Precursor;
312        let rt = *self.frame_to_rt.get(&frame_id).unwrap_or(&0.0) as f64;
313
314        // Single lookup instead of contains_key + get
315        let Some((peptide_ids, abundances)) = frame_to_abundances.get(&frame_id) else {
316            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
317        };
318
319        // Preallocate with estimated capacity
320        let estimated_capacity = peptide_ids.len() * 4;
321        let mut tims_spectra: Vec<TimsSpectrum> = Vec::with_capacity(estimated_capacity);
322
323        for (peptide_id, abundance) in peptide_ids.iter().zip(abundances.iter()) {
324            let Some((ion_abundances, scan_occurrences, scan_abundances, _, spectra)) =
325                peptide_to_ions.get(peptide_id)
326            else {
327                continue;
328            };
329
330            // Cache peptide-level lookup
331            let total_events = *peptide_to_events.get(peptide_id).unwrap_or(&1.0);
332
333            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
334                let scan_occurrence = &scan_occurrences[index];
335                let scan_abundance = &scan_abundances[index];
336                let spectrum = &spectra[index];
337
338                for (scan, scan_abu) in scan_occurrence.iter().zip(scan_abundance.iter()) {
339                    let abundance_factor = abundance * ion_abundance * scan_abu * total_events;
340                    let scaled_spec: MzSpectrum = spectrum.clone() * abundance_factor as f64;
341
342                    let mz_spectrum = if mz_noise_precursor {
343                        if uniform {
344                            scaled_spec.add_mz_noise_uniform_with_rng(precursor_noise_ppm, right_drag, &mut rng)
345                        } else {
346                            scaled_spec.add_mz_noise_normal_with_rng(precursor_noise_ppm, &mut rng)
347                        }
348                    } else {
349                        scaled_spec
350                    };
351
352                    let scan_mobility = *self.scan_to_mobility.get(scan).unwrap_or(&0.0) as f64;
353                    let spectrum_len = mz_spectrum.mz.len();
354
355                    tims_spectra.push(TimsSpectrum::new(
356                        frame_id as i32,
357                        *scan as i32,
358                        rt,
359                        scan_mobility,
360                        ms_type.clone(),
361                        IndexedMzSpectrum::from_mz_spectrum(
362                            vec![0; spectrum_len],
363                            mz_spectrum,
364                        ),
365                    ));
366                }
367            }
368        }
369
370        let mut filtered = TimsFrame::from_tims_spectra_filtered(
371            tims_spectra, 0.0, 10000.0, 0, 2000, 0.0, 10.0, 1.0, 1e9,
372        );
373
374        // Round intensities
375        let intensities_rounded: Vec<f64> = filtered
376            .ims_frame
377            .intensity
378            .iter()
379            .map(|x| x.round())
380            .collect();
381        filtered.ims_frame.intensity = Arc::new(intensities_rounded);
382
383        filtered
384    }
385
386    /// Build a fragment (MS2) frame.
387    #[allow(clippy::too_many_arguments)]
388    fn build_fragment_frame(
389        &self,
390        frame_id: u32,
391        fragmentation: bool,
392        mz_noise_fragment: bool,
393        uniform: bool,
394        fragment_noise_ppm: f64,
395        right_drag: bool,
396        peptide_to_ions: &BTreeMap<u32, (Vec<f32>, Vec<Vec<u32>>, Vec<Vec<f32>>, Vec<i8>, Vec<MzSpectrum>)>,
397        frame_to_abundances: &BTreeMap<u32, (Vec<u32>, Vec<f32>)>,
398        peptide_to_events: &BTreeMap<u32, f32>,
399        fragment_ions_map: &Option<BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrum>)>>,
400    ) -> TimsFrame {
401        // One RNG per frame, keyed by the master seed and the frame id, so the m/z jitter
402        // does not depend on which thread builds the frame. See mscore::simulation::noise_rng.
403        let mut rng = noise_rng(self.noise_seed, &[frame_id as u64]);
404        let ms_type = MsType::FragmentDia;
405        let rt = *self.frame_to_rt.get(&frame_id).unwrap_or(&0.0) as f64;
406
407        if !fragmentation || fragment_ions_map.is_none() {
408            // If no fragmentation, build a quadrupole-filtered precursor frame
409            let precursor_frame = self.build_precursor_frame(
410                frame_id,
411                mz_noise_fragment,
412                uniform,
413                fragment_noise_ppm,
414                right_drag,
415                peptide_to_ions,
416                frame_to_abundances,
417                peptide_to_events,
418            );
419            let mut frame = self.transmission_settings.transmit_tims_frame(&precursor_frame, None);
420            frame.ms_type = MsType::FragmentDia;
421            return frame;
422        }
423
424        let fragment_ions = fragment_ions_map.as_ref().unwrap();
425
426        // Single lookup instead of contains_key + get
427        let Some((peptide_ids, frame_abundances)) = frame_to_abundances.get(&frame_id) else {
428            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
429        };
430
431        // Preallocate with estimated capacity
432        let estimated_capacity = peptide_ids.len() * 4;
433        let mut tims_spectra: Vec<TimsSpectrum> = Vec::with_capacity(estimated_capacity);
434
435        for (peptide_id, frame_abundance) in peptide_ids.iter().zip(frame_abundances.iter()) {
436            let Some((ion_abundances, scan_occurrences, scan_abundances, charges, spectra)) =
437                peptide_to_ions.get(peptide_id)
438            else {
439                continue;
440            };
441
442            // Cache peptide-level lookup
443            let total_events = *peptide_to_events.get(peptide_id).unwrap_or(&1.0);
444
445            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
446                let all_scan_occurrence = &scan_occurrences[index];
447                let all_scan_abundance = &scan_abundances[index];
448                let spectrum = &spectra[index];
449                let charge_state = charges[index];
450
451                for (scan, scan_abundance) in all_scan_occurrence.iter().zip(all_scan_abundance.iter()) {
452                    // Check if precursor is transmitted
453                    if !self.transmission_settings.any_transmitted(
454                        frame_id as i32,
455                        *scan as i32,
456                        &spectrum.mz,
457                        None,
458                    ) {
459                        continue;
460                    }
461
462                    // Calculate abundance factor
463                    let fraction_events = frame_abundance * scan_abundance * ion_abundance * total_events;
464
465                    // Get collision energy
466                    let collision_energy = self.fragmentation_settings.get_collision_energy(
467                        frame_id as i32,
468                        *scan as i32,
469                    );
470                    // Resolve the fragment CE key tolerant to ~0.1 eV quantization
471                    // noise (same fix as eager render); fail loud on a real CE miss.
472                    let Some(collision_energy_quantized) = crate::sim::handle::resolve_fragment_ce_key(
473                        fragment_ions, *peptide_id, charge_state, collision_energy,
474                    ) else {
475                        if crate::sim::handle::fragment_prefix_exists(fragment_ions, *peptide_id, charge_state) {
476                            panic!(
477                                "lazy DIA fragment lookup miss: peptide {} charge {} applied CE {:.4} eV \
478                                 has predicted fragments, but none within 0.1 eV — the prediction set \
479                                 does not cover this instrument's collision energy",
480                                *peptide_id, charge_state, collision_energy,
481                            );
482                        }
483                        continue;
484                    };
485                    let (_, fragment_series_vec) = fragment_ions
486                        .get(&(*peptide_id, charge_state, collision_energy_quantized))
487                        .expect("resolve_fragment_ce_key returned a present key");
488
489                    // Cache scan mobility
490                    let scan_mobility = *self.scan_to_mobility.get(scan).unwrap_or(&0.0) as f64;
491
492                    for fragment_ion_series in fragment_series_vec.iter() {
493                        let scaled_spec = fragment_ion_series.clone() * fraction_events as f64;
494
495                        let mz_spectrum = if mz_noise_fragment {
496                            if uniform {
497                                scaled_spec.add_mz_noise_uniform_with_rng(fragment_noise_ppm, right_drag, &mut rng)
498                            } else {
499                                scaled_spec.add_mz_noise_normal_with_rng(fragment_noise_ppm, &mut rng)
500                            }
501                        } else {
502                            scaled_spec
503                        };
504
505                        let spectrum_len = mz_spectrum.mz.len();
506                        tims_spectra.push(TimsSpectrum::new(
507                            frame_id as i32,
508                            *scan as i32,
509                            rt,
510                            scan_mobility,
511                            ms_type.clone(),
512                            IndexedMzSpectrum::from_mz_spectrum(
513                                vec![0; spectrum_len],
514                                mz_spectrum,
515                            ).filter_ranged(100.0, 1700.0, 1.0, 1e9),
516                        ));
517                    }
518                }
519            }
520        }
521
522        if tims_spectra.is_empty() {
523            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
524        }
525
526        let mut filtered = TimsFrame::from_tims_spectra_filtered(
527            tims_spectra, 100.0, 1700.0, 0, 1000, 0.0, 10.0, 1.0, 1e9,
528        );
529
530        // Round intensities
531        let intensities_rounded: Vec<f64> = filtered
532            .ims_frame
533            .intensity
534            .iter()
535            .map(|x| x.round())
536            .collect();
537        filtered.ims_frame.intensity = Arc::new(intensities_rounded);
538
539        filtered
540    }
541
542    /// Get the total number of frames.
543    pub fn num_frames(&self) -> usize {
544        self.frames.len()
545    }
546
547    /// Get all frame IDs.
548    pub fn frame_ids(&self) -> Vec<u32> {
549        self.frames.iter().map(|f| f.frame_id).collect()
550    }
551
552    /// Get precursor frame IDs.
553    pub fn precursor_frame_ids(&self) -> Vec<u32> {
554        self.precursor_frame_id_set.iter().cloned().collect()
555    }
556
557    /// Get fragment frame IDs.
558    pub fn fragment_frame_ids(&self) -> Vec<u32> {
559        self.frames
560            .iter()
561            .filter(|f| !self.precursor_frame_id_set.contains(&f.frame_id))
562            .map(|f| f.frame_id)
563            .collect()
564    }
565}
566
567impl TimsTofCollisionEnergy for TimsTofLazyFrameBuilderDIA {
568    fn get_collision_energy(&self, frame_id: i32, scan_id: i32) -> f64 {
569        self.fragmentation_settings.get_collision_energy(frame_id, scan_id)
570    }
571}
572
573/// A lazy frame builder for DDA experiments that only loads data as needed.
574///
575/// Unlike `TimsTofSyntheticsFrameBuilderDDA`, this struct does not load all peptides,
576/// ions, and fragment ions into memory at construction time. Instead, it stores only
577/// the static metadata (frame info, scan info, transmission settings) and loads
578/// peptide/ion data on-demand for each batch of frames being built.
579///
580/// This can significantly reduce memory usage for large simulations.
581pub struct TimsTofLazyFrameBuilderDDA {
582    /// Path to the SQLite database
583    pub db_path: String,
584    /// Frame metadata (id, time, ms_type)
585    pub frames: Vec<FramesSim>,
586    /// Scan metadata (scan_id, mobility)
587    pub scans: Vec<ScansSim>,
588    /// Set of precursor frame IDs for quick lookup
589    pub precursor_frame_id_set: HashSet<u32>,
590    /// Map from frame_id to retention_time
591    pub frame_to_rt: BTreeMap<u32, f32>,
592    /// Map from scan_id to mobility
593    pub scan_to_mobility: BTreeMap<u32, f32>,
594    /// DDA transmission settings (includes PASEF metadata with collision energies)
595    pub transmission_settings: TimsTransmissionDDA,
596    /// Number of threads for parallel processing
597    pub num_threads: usize,
598    /// Source for occurrence/abundance distributions (legacy columns or projector)
599    pub source: DistributionSource,
600}
601
602impl TimsTofLazyFrameBuilderDDA {
603    /// Create a new lazy frame builder for DDA.
604    ///
605    /// Only loads static metadata (frames, scans, transmission settings).
606    /// Peptides, ions, and fragment ions are NOT loaded here.
607    ///
608    /// # Arguments
609    ///
610    /// * `path` - Path to the SQLite database
611    /// * `num_threads` - Number of threads for parallel operations
612    ///
613    /// # Returns
614    ///
615    /// Result containing the lazy frame builder
616    pub fn new(path: &Path, num_threads: usize) -> rusqlite::Result<Self> {
617        Self::new_with_source(path, num_threads, DistributionSource::Columns)
618    }
619
620    /// Like `new`, but reads occurrence/abundance distributions from `source`
621    /// (legacy columns by default, or the render-time projector).
622    pub fn new_with_source(
623        path: &Path,
624        num_threads: usize,
625        source: DistributionSource,
626    ) -> rusqlite::Result<Self> {
627        let handle = TimsTofSyntheticsDataHandle::new(path)?;
628        // P5b: refuse an incompatible fragment prediction set (Bruker/legacy pass).
629        handle
630            .read_prediction_set()?
631            .assert_render_compatible()
632            .map_err(|_| rusqlite::Error::InvalidQuery)?;
633
634        let frames = handle.read_frames()?;
635        let scans = handle.read_scans()?;
636
637        let precursor_frame_id_set = TimsTofSyntheticsDataHandle::build_precursor_frame_id_set(&frames);
638        let frame_to_rt = TimsTofSyntheticsDataHandle::build_frame_to_rt(&frames);
639        let scan_to_mobility = TimsTofSyntheticsDataHandle::build_scan_to_mobility(&scans);
640
641        let transmission_settings = handle.get_transmission_dda();
642
643        Ok(Self {
644            db_path: path.to_str().unwrap().to_string(),
645            frames,
646            scans,
647            precursor_frame_id_set,
648            frame_to_rt,
649            scan_to_mobility,
650            transmission_settings,
651            num_threads,
652            source,
653        })
654    }
655
656    /// Load data for a specific frame range from the database.
657    ///
658    /// Returns peptides, ions, and fragment ions that are relevant to the frame range.
659    fn load_data_for_frame_range(
660        &self,
661        frame_min: u32,
662        frame_max: u32,
663    ) -> rusqlite::Result<(Vec<PeptidesSim>, Vec<IonSim>, Vec<FragmentIonSim>)> {
664        let path = Path::new(&self.db_path);
665        let handle = TimsTofSyntheticsDataHandle::new(path)?;
666
667        // Load only peptides for this frame range (source-aware: columns or projector)
668        let peptides = handle.read_peptides_for_frame_range_with_source(frame_min, frame_max, &self.source)?;
669
670        if peptides.is_empty() {
671            return Ok((Vec::new(), Vec::new(), Vec::new()));
672        }
673
674        // Get peptide IDs for querying related data
675        let peptide_ids: Vec<u32> = peptides.iter().map(|p| p.peptide_id).collect();
676
677        // Load ions and fragment ions for these peptides
678        let ions = handle.read_ions_for_peptides_with_source(&peptide_ids, &self.source)?;
679        let fragment_ions = handle.read_fragment_ions_for_peptides(&peptide_ids)?;
680
681        Ok((peptides, ions, fragment_ions))
682    }
683
684    /// Build frames for a range of frame IDs.
685    ///
686    /// This method loads only the data needed for the specified frames,
687    /// builds the frames, and then releases the loaded data.
688    ///
689    /// # Arguments
690    ///
691    /// * `frame_ids` - Vector of frame IDs to build
692    /// * `fragmentation` - Whether to include fragmentation
693    /// * `mz_noise_precursor` - Whether to add m/z noise to precursor ions
694    /// * `uniform` - Whether to use uniform noise distribution
695    /// * `precursor_noise_ppm` - Precursor noise in ppm
696    /// * `mz_noise_fragment` - Whether to add m/z noise to fragment ions
697    /// * `fragment_noise_ppm` - Fragment noise in ppm
698    /// * `right_drag` - Whether to use right drag for noise
699    ///
700    /// # Returns
701    ///
702    /// Vector of built TimsFrame instances
703    pub fn build_frames_lazy(
704        &self,
705        frame_ids: Vec<u32>,
706        fragmentation: bool,
707        mz_noise_precursor: bool,
708        uniform: bool,
709        precursor_noise_ppm: f64,
710        mz_noise_fragment: bool,
711        fragment_noise_ppm: f64,
712        right_drag: bool,
713    ) -> Vec<TimsFrame> {
714        if frame_ids.is_empty() {
715            return Vec::new();
716        }
717
718        // Determine frame range
719        let frame_min = *frame_ids.iter().min().unwrap();
720        let frame_max = *frame_ids.iter().max().unwrap();
721
722        // Load data for this frame range
723        let (peptides, ions, fragment_ions) = match self.load_data_for_frame_range(frame_min, frame_max) {
724            Ok(data) => data,
725            Err(_) => return Vec::new(),
726        };
727
728        // Delegate to the EAGER DDA builder over this per-batch slice. This is the
729        // single source of truth for DDA frame construction — the lazy builder
730        // does NOT keep a second copy of the precursor/fragment algorithm (an
731        // earlier copy diverged: it fragmented every transmitted peptide instead
732        // of only the PASEF-selected precursor). Memory stays bounded to the batch
733        // because we feed only the slice's peptides/ions/fragment_ions.
734        let precursor_builder = TimsTofSyntheticsPrecursorFrameBuilder::from_entities(
735            ions,
736            peptides,
737            self.scans.clone(),
738            self.frames.clone(),
739        );
740        let dda_builder = TimsTofSyntheticsFrameBuilderDDA::from_entities(
741            precursor_builder,
742            self.transmission_settings.clone(),
743            fragment_ions,
744            None,
745            fragmentation,
746            self.num_threads,
747        );
748
749        dda_builder.build_frames(
750            frame_ids,
751            fragmentation,
752            mz_noise_precursor,
753            uniform,
754            precursor_noise_ppm,
755            mz_noise_fragment,
756            fragment_noise_ppm,
757            right_drag,
758            self.num_threads,
759        )
760    }
761
762    /// Get collision energy for a frame/scan combination from PASEF metadata.
763    pub fn get_collision_energy(&self, frame_id: i32, scan_id: i32) -> f64 {
764        self.transmission_settings.get_collision_energy(frame_id, scan_id).unwrap_or(0.0)
765    }
766
767    /// Get the total number of frames.
768    pub fn num_frames(&self) -> usize {
769        self.frames.len()
770    }
771
772    /// Get all frame IDs.
773    pub fn frame_ids(&self) -> Vec<u32> {
774        self.frames.iter().map(|f| f.frame_id).collect()
775    }
776
777    /// Get precursor frame IDs.
778    pub fn precursor_frame_ids(&self) -> Vec<u32> {
779        self.precursor_frame_id_set.iter().cloned().collect()
780    }
781
782    /// Get fragment frame IDs.
783    pub fn fragment_frame_ids(&self) -> Vec<u32> {
784        self.frames
785            .iter()
786            .filter(|f| !self.precursor_frame_id_set.contains(&f.frame_id))
787            .map(|f| f.frame_id)
788            .collect()
789    }
790}