Skip to main content

rustdf/data/
dda.rs

1use crate::data::acquisition::AcquisitionMode;
2use crate::data::handle::{IndexConverter, TimsData, TimsDataLoader};
3use crate::data::meta::{read_dda_precursor_meta, read_global_meta_sql, read_meta_data_sql, read_pasef_frame_ms_ms_info, DDAPrecursor, DDAPrecursorMeta, PasefMsMsMeta};
4use mscore::timstof::frame::{ImsFrame, RawTimsFrame, TimsFrame};
5use mscore::timstof::slice::TimsSlice;
6use mscore::timstof::spectrum_processing::{
7    PASEFFragmentData, PreprocessedSpectrum, SpectrumProcessingConfig,
8    process_pasef_fragments_batch,
9};
10use rayon::prelude::*;
11use rayon::ThreadPoolBuilder;
12use std::collections::BTreeMap;
13use rand::prelude::IteratorRandom;
14use mscore::data::spectrum::MsType;
15use std::collections::HashMap;
16
17#[derive(Clone)]
18pub struct PASEFDDAFragment {
19    pub frame_id: u32,
20    pub precursor_id: u32,
21    pub collision_energy: f64,
22    pub selected_fragment: TimsFrame,
23}
24
25/// Statistical moments of a 1D signal distribution
26#[derive(Clone, Debug, Default)]
27pub struct SignalMoments {
28    pub mean: f64,
29    pub variance: f64,
30    pub skewness: f64,
31    pub apex: f64,
32    pub fwhm: f64,
33    pub total_intensity: f64,
34}
35
36impl SignalMoments {
37    /// Calculate moments from coordinate and intensity arrays
38    pub fn from_signal(coords: &[f64], intensities: &[f64]) -> Self {
39        if coords.is_empty() || intensities.iter().sum::<f64>() == 0.0 {
40            return Self::default();
41        }
42
43        let total: f64 = intensities.iter().sum();
44
45        // First moment (weighted mean)
46        let mean: f64 = coords.iter()
47            .zip(intensities.iter())
48            .map(|(c, i)| c * i / total)
49            .sum();
50
51        // Second moment (weighted variance)
52        let variance: f64 = coords.iter()
53            .zip(intensities.iter())
54            .map(|(c, i)| i / total * (c - mean).powi(2))
55            .sum();
56
57        // Third moment (weighted skewness)
58        let std = variance.sqrt().max(1e-10);
59        let skewness: f64 = coords.iter()
60            .zip(intensities.iter())
61            .map(|(c, i)| i / total * ((c - mean) / std).powi(3))
62            .sum();
63
64        // Apex (position of maximum)
65        let apex_idx = intensities.iter()
66            .enumerate()
67            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
68            .map(|(i, _)| i)
69            .unwrap_or(0);
70        let apex = coords.get(apex_idx).copied().unwrap_or(0.0);
71
72        // FWHM estimation
73        let half_max = intensities.get(apex_idx).copied().unwrap_or(0.0) / 2.0;
74        let above_half: Vec<f64> = coords.iter()
75            .zip(intensities.iter())
76            .filter(|(_, i)| **i >= half_max)
77            .map(|(c, _)| *c)
78            .collect();
79        let fwhm = if above_half.len() >= 2 {
80            above_half.last().unwrap_or(&0.0) - above_half.first().unwrap_or(&0.0)
81        } else {
82            2.355 * std  // Gaussian approximation
83        };
84
85        SignalMoments {
86            mean,
87            variance,
88            skewness,
89            apex,
90            fwhm,
91            total_intensity: total,
92        }
93    }
94}
95
96/// MS1 precursor signal extracted from surrounding frames
97#[derive(Clone, Debug)]
98pub struct PrecursorMS1Signal {
99    pub precursor_id: u32,
100
101    // XIC (chromatographic profile) - 1D projection
102    pub rt_coords: Vec<f64>,           // RT in seconds
103    pub rt_intensities: Vec<f64>,
104    pub rt_moments: SignalMoments,
105
106    // Mobilogram (IM profile) - 1D projection
107    pub im_coords: Vec<f64>,           // 1/K0
108    pub im_intensities: Vec<f64>,
109    pub im_moments: SignalMoments,
110
111    // Isotope envelope - 1D projection
112    pub isotope_mz: Vec<f64>,
113    pub isotope_intensity: Vec<f64>,
114    pub mz_moments: SignalMoments,
115
116    // Raw 2D data (all peaks from filtered MS1 frames in RT window, merged)
117    pub raw_rt: Vec<f64>,              // RT per peak (seconds)
118    pub raw_mz: Vec<f64>,              // m/z per peak
119    pub raw_mobility: Vec<f64>,        // 1/K0 per peak
120    pub raw_intensity: Vec<f64>,       // intensity per peak
121}
122
123/// Input for MS1 extraction - precursor coordinates
124#[derive(Clone, Debug)]
125pub struct PrecursorCoord {
126    pub precursor_id: u32,
127    pub mz: f64,           // largest_peak_mz - used as fallback if mono_mz is 0
128    pub mono_mz: f64,      // Monoisotopic m/z for isotope envelope (M+0 starting point), 0 if unknown
129    pub rt_seconds: f64,
130    pub mobility: f64,     // Center mobility from fragment
131    pub im_start: f64,     // Fragment selection IM start (for plotting)
132    pub im_end: f64,       // Fragment selection IM end (for plotting)
133    pub charge: i32,
134}
135
136pub struct TimsDatasetDDA {
137    pub loader: TimsDataLoader,
138    pub pasef_meta: Vec<PasefMsMsMeta>,
139}
140
141impl TimsDatasetDDA {
142    pub fn new(
143        bruker_lib_path: &str,
144        data_path: &str,
145        in_memory: bool,
146        use_bruker_sdk: bool,
147    ) -> Self {
148        // TODO: error handling
149        let global_meta_data = read_global_meta_sql(data_path).unwrap();
150        let meta_data = read_meta_data_sql(data_path).unwrap();
151
152        let scan_max_index = meta_data.iter().map(|x| x.num_scans).max().unwrap() as u32;
153        let im_lower = global_meta_data.one_over_k0_range_lower;
154        let im_upper = global_meta_data.one_over_k0_range_upper;
155
156        let tof_max_index = global_meta_data.tof_max_index;
157        let mz_lower = global_meta_data.mz_acquisition_range_lower;
158        let mz_upper = global_meta_data.mz_acquisition_range_upper;
159
160        let loader = match in_memory {
161            true => TimsDataLoader::new_in_memory(
162                bruker_lib_path,
163                data_path,
164                use_bruker_sdk,
165                scan_max_index,
166                im_lower,
167                im_upper,
168                tof_max_index,
169                mz_lower,
170                mz_upper,
171            ),
172            false => TimsDataLoader::new_lazy(
173                bruker_lib_path,
174                data_path,
175                use_bruker_sdk,
176                scan_max_index,
177                im_lower,
178                im_upper,
179                tof_max_index,
180                mz_lower,
181                mz_upper,
182            ),
183        };
184        
185        let pasef_meta = read_pasef_frame_ms_ms_info(data_path).unwrap();
186
187        TimsDatasetDDA { loader, pasef_meta }
188    }
189
190    /// Create a new DDA dataset with pre-computed ion mobility calibration lookup table.
191    ///
192    /// This enables accurate ion mobility calibration with fast parallel extraction.
193    /// The im_lookup table should be pre-computed using the Bruker SDK.
194    ///
195    /// # Arguments
196    /// * `data_path` - Path to the .d folder
197    /// * `in_memory` - Whether to load all data into memory
198    /// * `bruker_lib_path` - Path to the Bruker SDK shared library; used to
199    ///   derive an accurate m/z calibration. Pass "NO_SDK" (or an empty
200    ///   string) to skip and use the 2-point boundary m/z model.
201    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table from Bruker SDK
202    ///
203    /// # Returns
204    /// A new TimsDatasetDDA with LookupIndexConverter (thread-safe, accurate)
205    pub fn new_with_calibration(
206        data_path: &str,
207        in_memory: bool,
208        bruker_lib_path: &str,
209        im_lookup: Vec<f64>,
210    ) -> Self {
211        let global_meta_data = read_global_meta_sql(data_path).unwrap();
212
213        let tof_max_index = global_meta_data.tof_max_index;
214        let mz_lower = global_meta_data.mz_acquisition_range_lower;
215        let mz_upper = global_meta_data.mz_acquisition_range_upper;
216
217        let loader = match in_memory {
218            true => TimsDataLoader::new_in_memory_with_calibration(
219                data_path,
220                bruker_lib_path,
221                tof_max_index,
222                mz_lower,
223                mz_upper,
224                im_lookup,
225            ),
226            false => TimsDataLoader::new_lazy_with_calibration(
227                data_path,
228                bruker_lib_path,
229                tof_max_index,
230                mz_lower,
231                mz_upper,
232                im_lookup,
233            ),
234        };
235
236        let pasef_meta = read_pasef_frame_ms_ms_info(data_path).unwrap();
237
238        TimsDatasetDDA { loader, pasef_meta }
239    }
240
241    /// Create a DDA dataset with regression-derived m/z calibration.
242    ///
243    /// This method uses externally-provided m/z calibration coefficients (e.g., from
244    /// linear regression on precursor data) instead of the simple boundary model.
245    ///
246    /// # Arguments
247    /// * `data_path` - Path to the .d folder
248    /// * `in_memory` - Whether to load all data into memory
249    /// * `tof_intercept` - Intercept for sqrt(mz) = intercept + slope * tof
250    /// * `tof_slope` - Slope for sqrt(mz) = intercept + slope * tof
251    ///
252    /// # Returns
253    /// A new TimsDatasetDDA with CalibratedIndexConverter (thread-safe, accurate)
254    pub fn new_with_mz_calibration(
255        data_path: &str,
256        in_memory: bool,
257        tof_intercept: f64,
258        tof_slope: f64,
259    ) -> Self {
260        let global_meta_data = read_global_meta_sql(data_path).unwrap();
261        let frame_meta = read_meta_data_sql(data_path).unwrap();
262
263        let scan_max_index = frame_meta.iter().map(|x| x.num_scans).max().unwrap() as u32;
264        let im_lower = global_meta_data.one_over_k0_range_lower;
265        let im_upper = global_meta_data.one_over_k0_range_upper;
266
267        let loader = match in_memory {
268            true => TimsDataLoader::new_in_memory_with_mz_calibration(
269                data_path,
270                tof_intercept,
271                tof_slope,
272                im_lower,
273                im_upper,
274                scan_max_index,
275            ),
276            false => TimsDataLoader::new_lazy_with_mz_calibration(
277                data_path,
278                tof_intercept,
279                tof_slope,
280                im_lower,
281                im_upper,
282                scan_max_index,
283            ),
284        };
285
286        let pasef_meta = read_pasef_frame_ms_ms_info(data_path).unwrap();
287
288        TimsDatasetDDA { loader, pasef_meta }
289    }
290
291    /// Create a DDA dataset using the exact SDK-free Bruker calibration formulas.
292    ///
293    /// Reads the `MzCalibration` / `TimsCalibration` tables and converts axes
294    /// with [`crate::data::calibration`] — no Bruker SDK at build or runtime.
295    /// 1/K0 is machine-exact; m/z is bit-exact for MzCalibration ModelType 1 and
296    /// accurate to a few ppm for ModelType 2. `calibration_frame_id` selects the
297    /// frame whose calibration is used (default 1; near-constant per run).
298    pub fn new_with_bruker_formula(
299        data_path: &str,
300        in_memory: bool,
301        calibration_frame_id: u32,
302    ) -> Self {
303        let loader = match in_memory {
304            true => TimsDataLoader::new_in_memory_with_bruker_formula(
305                data_path,
306                calibration_frame_id,
307            ),
308            false => {
309                TimsDataLoader::new_lazy_with_bruker_formula(data_path, calibration_frame_id)
310            }
311        };
312        let pasef_meta = read_pasef_frame_ms_ms_info(data_path).unwrap();
313        TimsDatasetDDA { loader, pasef_meta }
314    }
315
316    /// Check if the Bruker SDK is being used for index conversion.
317    /// Returns false for both Simple and Lookup converters (which are thread-safe).
318    pub fn uses_bruker_sdk(&self) -> bool {
319        self.loader.uses_bruker_sdk()
320    }
321
322    pub fn get_selected_precursors(&self) -> Vec<DDAPrecursor> {
323        let precursor_meta = read_dda_precursor_meta(&self.loader.get_data_path()).unwrap();
324        let pasef_meta = &self.pasef_meta;
325
326        let precursor_id_to_pasef_meta: BTreeMap<i64, &PasefMsMsMeta> = pasef_meta
327            .iter()
328            .map(|x| (x.precursor_id as i64, x))
329            .collect();
330
331        // go over all precursors and get the precursor meta data
332        let result: Vec<_> = precursor_meta
333            .iter()
334            .map(|precursor| {
335                let pasef_meta = precursor_id_to_pasef_meta
336                    .get(&precursor.precursor_id)
337                    .unwrap();
338                DDAPrecursor {
339                    frame_id: precursor.precursor_frame_id,
340                    precursor_id: precursor.precursor_id,
341                    mono_mz: precursor.precursor_mz_monoisotopic,
342                    highest_intensity_mz: precursor.precursor_mz_highest_intensity,
343                    average_mz: precursor.precursor_mz_average,
344                    charge: precursor.precursor_charge,
345                    inverse_ion_mobility: self.scan_to_inverse_mobility(
346                        precursor.precursor_frame_id as u32,
347                        &vec![precursor.precursor_average_scan_number as u32],
348                    )[0],
349                    collision_energy: pasef_meta.collision_energy,
350                    precuror_total_intensity: precursor.precursor_total_intensity,
351                    isolation_mz: pasef_meta.isolation_mz,
352                    isolation_width: pasef_meta.isolation_width,
353                }
354            })
355            .collect();
356
357        result
358    }
359
360    pub fn get_precursor_frames(
361        &self,
362        min_intensity: f64,
363        max_num_peaks: usize,
364        num_threads: usize,
365    ) -> Vec<TimsFrame> {
366        // get all precursor frames
367        let meta_data = read_meta_data_sql(&self.loader.get_data_path()).unwrap();
368
369        // get the precursor frames
370        let precursor_frames = meta_data.iter().filter(|x| x.ms_ms_type == 0);
371
372        let tims_silce =
373            self.get_slice(precursor_frames.map(|x| x.id as u32).collect(), num_threads);
374
375        let result: Vec<_> = tims_silce
376            .frames
377            .par_iter()
378            .map(|frame| {
379                frame
380                    .filter_ranged(0.0, 2000.0, 0, 2000, 0.0, 5.0, min_intensity, 1e9, 0, i32::MAX)
381                    .top_n(max_num_peaks)
382            })
383            .collect();
384
385        result
386    }
387
388    /// Extract MS1 precursor signals for a batch of precursors in parallel.
389    ///
390    /// For each precursor, extracts:
391    /// - XIC (chromatographic profile) from MS1 frames in RT window
392    /// - Mobilogram (IM profile)
393    /// - Isotope envelope
394    /// - Statistical moments (mean, variance, skewness, apex, FWHM) for each dimension
395    ///
396    /// Uses batched processing to avoid loading all MS1 frames at once.
397    ///
398    /// # Arguments
399    /// * `precursor_coords` - Vector of precursor coordinates (id, mz, rt_sec, mobility, charge)
400    /// * `rt_window_sec` - RT window in seconds (total width)
401    /// * `mz_tol_ppm` - m/z tolerance in ppm
402    /// * `im_window` - IM window in 1/K0 units (total width)
403    /// * `n_isotopes` - Number of isotope peaks to extract
404    /// * `num_threads` - Number of threads for parallel processing
405    ///
406    /// # Returns
407    /// Vector of PrecursorMS1Signal, one per input precursor
408    pub fn extract_precursor_ms1_signals(
409        &self,
410        precursor_coords: Vec<PrecursorCoord>,
411        rt_window_sec: f64,
412        mz_tol_ppm: f64,
413        im_window: f64,
414        n_isotopes: usize,
415        num_threads: usize,
416    ) -> Vec<PrecursorMS1Signal> {
417        if precursor_coords.is_empty() {
418            return Vec::new();
419        }
420
421        // Get frame metadata
422        let meta_data = read_meta_data_sql(&self.loader.get_data_path()).unwrap();
423
424        // Get all MS1 frame info sorted by time
425        let mut ms1_frame_info: Vec<(u32, f64)> = meta_data
426            .iter()
427            .filter(|f| f.ms_ms_type == 0)
428            .map(|f| (f.id as u32, f.time))
429            .collect();
430        ms1_frame_info.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
431
432        let ms1_times: Vec<f64> = ms1_frame_info.iter().map(|(_, t)| *t).collect();
433
434        // Sort precursors by RT for batched processing
435        let mut sorted_coords: Vec<(usize, &PrecursorCoord)> = precursor_coords
436            .iter()
437            .enumerate()
438            .collect();
439        sorted_coords.sort_by(|a, b| a.1.rt_seconds.partial_cmp(&b.1.rt_seconds).unwrap());
440
441        // Process in RT batches (5 minute chunks = 300 sec)
442        let batch_size_sec = 300.0;
443        let mut results: Vec<(usize, PrecursorMS1Signal)> = Vec::with_capacity(precursor_coords.len());
444
445        let mut batch_start = 0;
446        while batch_start < sorted_coords.len() {
447            // Find batch end (all precursors within batch_size_sec of first)
448            let batch_rt_start = sorted_coords[batch_start].1.rt_seconds;
449            let batch_rt_end = batch_rt_start + batch_size_sec;
450
451            let mut batch_end = batch_start;
452            while batch_end < sorted_coords.len() && sorted_coords[batch_end].1.rt_seconds < batch_rt_end {
453                batch_end += 1;
454            }
455
456            // Determine MS1 frames needed for this batch (with RT window margin)
457            let frame_rt_min = batch_rt_start - rt_window_sec;
458            let frame_rt_max = batch_rt_end + rt_window_sec;
459
460            let frame_start_idx = ms1_times.partition_point(|t| *t < frame_rt_min);
461            let frame_end_idx = ms1_times.partition_point(|t| *t <= frame_rt_max);
462
463            // Load frames for this batch
464            let batch_frame_ids: Vec<u32> = ms1_frame_info[frame_start_idx..frame_end_idx]
465                .iter()
466                .map(|(id, _)| *id)
467                .collect();
468
469            let batch_frames = if !batch_frame_ids.is_empty() {
470                self.loader.get_slice(batch_frame_ids, num_threads)
471            } else {
472                TimsSlice { frames: Vec::new() }
473            };
474
475            let batch_times: Vec<f64> = ms1_times[frame_start_idx..frame_end_idx].to_vec();
476
477            // Process precursors in this batch in parallel
478            let batch_coords = &sorted_coords[batch_start..batch_end];
479
480            let pool = ThreadPoolBuilder::new()
481                .num_threads(num_threads)
482                .build()
483                .unwrap();
484
485            let batch_results: Vec<(usize, PrecursorMS1Signal)> = pool.install(|| {
486                batch_coords.par_iter().map(|(orig_idx, coord)| {
487                    let signal = Self::extract_single_precursor(
488                        coord,
489                        &batch_frames.frames,
490                        &batch_times,
491                        rt_window_sec,
492                        mz_tol_ppm,
493                        im_window,
494                        n_isotopes,
495                    );
496                    (*orig_idx, signal)
497                }).collect()
498            });
499
500            results.extend(batch_results);
501            batch_start = batch_end;
502        }
503
504        // Restore original order
505        results.sort_by_key(|(idx, _)| *idx);
506        results.into_iter().map(|(_, signal)| signal).collect()
507    }
508
509    /// Extract MS1 signal for a single precursor from pre-loaded frames
510    fn extract_single_precursor(
511        coord: &PrecursorCoord,
512        frames: &[TimsFrame],
513        frame_times: &[f64],
514        rt_window_sec: f64,
515        mz_tol_ppm: f64,
516        im_window: f64,
517        n_isotopes: usize,
518    ) -> PrecursorMS1Signal {
519        let rt_sec = coord.rt_seconds;
520
521        // Binary search to find RT window bounds within batch frames
522        let rt_min = rt_sec - rt_window_sec / 2.0;
523        let rt_max = rt_sec + rt_window_sec / 2.0;
524
525        let start_idx = frame_times.partition_point(|t| *t < rt_min);
526        let end_idx = frame_times.partition_point(|t| *t <= rt_max);
527
528        // Calculate isotope spacing
529        let isotope_spacing = 1.003355 / (coord.charge.max(1) as f64);
530        let n_isotopes_to_extract = 4.min(n_isotopes); // M+0 to M+3 only
531
532        // Determine base m/z for extraction:
533        // - If mono_mz > 0: use it as starting point for isotope range
534        // - Otherwise: fall back to largest_peak_mz (single peak extraction)
535        let has_mono_mz = coord.mono_mz > 0.0;
536        let base_mz = if has_mono_mz { coord.mono_mz } else { coord.mz };
537        let mz_tol = base_mz * mz_tol_ppm / 1e6;
538
539        // Calculate isotope m/z values (M+0 through M+3)
540        let isotope_mz_values: Vec<f64> = (0..n_isotopes_to_extract)
541            .map(|i| base_mz + (i as f64) * isotope_spacing)
542            .collect();
543
544        // m/z range for XIC/mobilogram:
545        // - If mono_mz available: use full isotope range (M+0 to M+3) for better S/N
546        // - Otherwise: use single m/z (largest_peak_mz)
547        let (xic_mz_min, xic_mz_max) = if has_mono_mz {
548            // Full isotope range: M+0 - tolerance to M+3 + tolerance
549            (base_mz - mz_tol, base_mz + ((n_isotopes_to_extract - 1) as f64) * isotope_spacing + mz_tol)
550        } else {
551            // Single peak: largest_peak_mz ± tolerance
552            (coord.mz - mz_tol, coord.mz + mz_tol)
553        };
554
555        // IM range (uses im_window parameter, doubled from default for better coverage)
556        let im_min = coord.mobility - im_window / 2.0;
557        let im_max = coord.mobility + im_window / 2.0;
558
559        // Accumulators
560        let n_frames = end_idx.saturating_sub(start_idx);
561        let mut rt_coords = Vec::with_capacity(n_frames);
562        let mut rt_intensities = Vec::with_capacity(n_frames);
563        let mut im_dict: HashMap<i64, f64> = HashMap::new();
564        let mut isotope_intensity = vec![0.0f64; n_isotopes];  // Keep original size for output
565
566        // Accumulators for raw 2D data (merged from all frames)
567        let mut raw_rt = Vec::new();
568        let mut raw_mz = Vec::new();
569        let mut raw_mobility = Vec::new();
570        let mut raw_intensity = Vec::new();
571
572        // Extract from each MS1 frame in the RT window
573        for idx in start_idx..end_idx.min(frames.len()) {
574            let frame_time = frame_times[idx];
575            let frame = &frames[idx];
576
577            // === XIC and Mobilogram: Extract from isotope range (or single m/z if no mono_mz) ===
578            // Scan range is unbounded; the inv_mobility window (im_min/im_max) does
579            // the mobility selection. A hardcoded scan cap (e.g. 0..1000) silently
580            // drops signal on methods configured with more than that many scans
581            // per frame (high mobility-resolution / wide IM range acquisitions).
582            let xic_filtered = frame.filter_ranged(
583                xic_mz_min, xic_mz_max,
584                0, i32::MAX,
585                im_min, im_max,
586                0.0, 1e9,
587                0, i32::MAX,
588            );
589
590            // XIC: sum intensity at this RT from all isotopes
591            let xic_intensity: f64 = xic_filtered.ims_frame.intensity.iter().sum();
592            rt_coords.push(frame_time);
593            rt_intensities.push(xic_intensity);
594
595            // Mobilogram: accumulate from all isotopes in m/z range
596            for (mob, inten) in xic_filtered.ims_frame.mobility.iter().zip(xic_filtered.ims_frame.intensity.iter()) {
597                let mob_bin = (*mob * 1000.0).round() as i64;
598                *im_dict.entry(mob_bin).or_insert(0.0) += *inten;
599            }
600
601            // === Isotope envelope: Extract M+0 through M+3 individually ===
602            // Use same filtered data (already in isotope range)
603            for (iso_idx, iso_mz) in isotope_mz_values.iter().enumerate() {
604                let iso_peak_min = iso_mz - mz_tol;
605                let iso_peak_max = iso_mz + mz_tol;
606                let iso_intensity_sum: f64 = xic_filtered.ims_frame.mz.iter()
607                    .zip(xic_filtered.ims_frame.intensity.iter())
608                    .filter(|(mz, _)| **mz >= iso_peak_min && **mz <= iso_peak_max)
609                    .map(|(_, i)| *i)
610                    .sum();
611                isotope_intensity[iso_idx] += iso_intensity_sum;
612            }
613
614            // Raw 2D data: store all peaks from filtered range
615            let n_peaks = xic_filtered.ims_frame.mz.len();
616            for i in 0..n_peaks {
617                raw_rt.push(frame_time);
618                raw_mz.push(xic_filtered.ims_frame.mz[i]);
619                raw_mobility.push(xic_filtered.ims_frame.mobility[i]);
620                raw_intensity.push(xic_filtered.ims_frame.intensity[i]);
621            }
622        }
623
624        // Convert mobilogram accumulator to sorted arrays
625        let mut im_entries: Vec<(i64, f64)> = im_dict.into_iter().collect();
626        im_entries.sort_by_key(|(k, _)| *k);
627        let im_coords: Vec<f64> = im_entries.iter().map(|(k, _)| *k as f64 / 1000.0).collect();
628        let im_intensities: Vec<f64> = im_entries.iter().map(|(_, v)| *v).collect();
629
630        // Calculate moments
631        let rt_moments = SignalMoments::from_signal(&rt_coords, &rt_intensities);
632        let im_moments = SignalMoments::from_signal(&im_coords, &im_intensities);
633        let mz_moments = SignalMoments::from_signal(&isotope_mz_values, &isotope_intensity);
634
635        PrecursorMS1Signal {
636            precursor_id: coord.precursor_id,
637            rt_coords,
638            rt_intensities,
639            rt_moments,
640            im_coords,
641            im_intensities,
642            im_moments,
643            isotope_mz: isotope_mz_values,
644            isotope_intensity,
645            mz_moments,
646            raw_rt,
647            raw_mz,
648            raw_mobility,
649            raw_intensity,
650        }
651    }
652
653    pub fn get_pasef_frame_ms_ms_info(&self) -> Vec<PasefMsMsMeta> {
654        read_pasef_frame_ms_ms_info(&self.loader.get_data_path()).unwrap()
655    }
656
657    /// Get the fragment spectra for all PASEF selected precursors
658    pub fn get_pasef_fragments(&self, num_threads: usize) -> Vec<PASEFDDAFragment> {
659        // Delegate to the filtered version with no filter (all precursors)
660        self.get_pasef_fragments_for_precursors(None, num_threads)
661    }
662
663    /// Get fragment spectra for specific precursor IDs only.
664    /// If precursor_ids is None, returns all fragments (same as get_pasef_fragments).
665    /// This is more memory-efficient for batched processing.
666    pub fn get_pasef_fragments_for_precursors(
667        &self,
668        precursor_ids: Option<&[u32]>,
669        num_threads: usize,
670    ) -> Vec<PASEFDDAFragment> {
671        // extract fragment spectra information
672        let pasef_info = self.get_pasef_frame_ms_ms_info();
673
674        // Filter to requested precursor IDs if specified
675        let filtered_pasef_info: Vec<&PasefMsMsMeta> = match precursor_ids {
676            Some(ids) => {
677                // Create a HashSet for O(1) lookup
678                let id_set: std::collections::HashSet<u32> = ids.iter().copied().collect();
679                pasef_info.iter()
680                    .filter(|info| id_set.contains(&(info.precursor_id as u32)))
681                    .collect()
682            }
683            None => pasef_info.iter().collect(),
684        };
685
686        // Note: The Bruker SDK is NOT thread-safe, so we must use sequential iteration
687        // when the SDK is being used for index conversion.
688        let uses_bruker_sdk = self.loader.uses_bruker_sdk();
689
690        // Helper closure to process a single PASEF fragment
691        let process_fragment = |pasef_info: &PasefMsMsMeta| -> PASEFDDAFragment {
692            // get the frame
693            let frame = self.loader.get_frame(pasef_info.frame_id as u32);
694
695            // get five percent of the scan range
696            let scan_margin = (pasef_info.scan_num_end - pasef_info.scan_num_begin) / 20;
697
698            // get the fragment spectrum by scan range
699            let filtered_frame = frame.filter_ranged(
700                0.0,
701                2000.0,
702                (pasef_info.scan_num_begin - scan_margin) as i32,
703                (pasef_info.scan_num_end + scan_margin) as i32,
704                0.0,
705                5.0,
706                0.0,
707                1e9,
708                0,
709                i32::MAX,
710            );
711
712            PASEFDDAFragment {
713                frame_id: pasef_info.frame_id as u32,
714                precursor_id: pasef_info.precursor_id as u32,
715                collision_energy: pasef_info.collision_energy,
716                // flatten the spectrum
717                selected_fragment: filtered_frame,
718            }
719        };
720
721        if uses_bruker_sdk {
722            // Sequential processing when using Bruker SDK (not thread-safe)
723            filtered_pasef_info.iter().map(|info| process_fragment(info)).collect()
724        } else {
725            // Parallel processing when using simple index converter (thread-safe)
726            let pool = ThreadPoolBuilder::new()
727                .num_threads(num_threads)
728                .build()
729                .unwrap();
730
731            pool.install(|| {
732                filtered_pasef_info.par_iter().map(|info| process_fragment(info)).collect()
733            })
734        }
735    }
736
737    /// Get preprocessed PASEF fragments ready for database search.
738    /// This method performs parallel processing of all fragment spectra, including:
739    /// - Flattening frames across ion mobility dimension
740    /// - Deisotoping (optional)
741    /// - Filtering to top N peaks
742    /// - Computing inverse mobility along scan marginal
743    ///
744    /// # Arguments
745    /// * `dataset_name` - Name of the dataset for generating spec_ids
746    /// * `config` - Spectrum processing configuration
747    /// * `num_threads` - Number of threads to use for parallel processing
748    ///
749    /// # Returns
750    /// Vector of preprocessed spectra ready for Sage search
751    pub fn get_preprocessed_pasef_fragments(
752        &self,
753        dataset_name: &str,
754        config: SpectrumProcessingConfig,
755        num_threads: usize,
756    ) -> Vec<PreprocessedSpectrum> {
757        // Step 1: Get raw PASEF fragments info
758        let pasef_info = self.get_pasef_frame_ms_ms_info();
759
760        // Step 2: Get precursor metadata
761        let precursor_meta = read_dda_precursor_meta(&self.loader.get_data_path()).unwrap_or_default();
762        let frame_meta = read_meta_data_sql(&self.loader.get_data_path()).unwrap_or_default();
763
764        // Create lookup maps
765        let precursor_map: BTreeMap<i64, &DDAPrecursorMeta> = precursor_meta
766            .iter()
767            .map(|p| (p.precursor_id, p))
768            .collect();
769
770        let frame_time_map: BTreeMap<i64, f64> = frame_meta
771            .iter()
772            .map(|f| (f.id, f.time / 60.0))  // Convert to minutes
773            .collect();
774
775        // Step 3: Group PASEF info by precursor_id to aggregate re-fragmented precursors
776        // This matches Python's groupby('precursor_id').agg({'raw_data': 'sum', ...})
777        let mut pasef_by_precursor: BTreeMap<i64, Vec<&PasefMsMsMeta>> = BTreeMap::new();
778        for info in &pasef_info {
779            pasef_by_precursor
780                .entry(info.precursor_id)
781                .or_insert_with(Vec::new)
782                .push(info);
783        }
784
785        // Step 4: Build fragment data with aggregation (merge frames for same precursor)
786        // Note: The Bruker SDK is NOT thread-safe, so we must use sequential iteration
787        // when the SDK is being used for index conversion.
788        let uses_bruker_sdk = self.loader.uses_bruker_sdk();
789
790        // Helper closure to process a single precursor
791        let process_precursor = |(precursor_id, pasef_infos): (&i64, &Vec<&PasefMsMsMeta>)| -> Option<PASEFFragmentData> {
792            // Get precursor metadata
793            let precursor = precursor_map.get(precursor_id)?;
794
795            // Use first PASEF info for metadata (matches Python's 'first')
796            let first_pasef = pasef_infos.first()?;
797
798            // Get retention time from first frame
799            let scan_start_time = frame_time_map.get(&first_pasef.frame_id).copied().unwrap_or(0.0);
800
801            // Collect and merge all frames for this precursor (matches Python's 'sum')
802            let mut combined_scan = Vec::new();
803            let mut combined_mobility = Vec::new();
804            let mut combined_tof = Vec::new();
805            let mut combined_mz = Vec::new();
806            let mut combined_intensity = Vec::new();
807
808            for pasef_info in pasef_infos {
809                // Get the frame and filter by scan range
810                let frame = self.loader.get_frame(pasef_info.frame_id as u32);
811
812                // Get five percent of the scan range for margin
813                let scan_margin = (pasef_info.scan_num_end - pasef_info.scan_num_begin) / 20;
814
815                // Filter frame by scan range
816                let filtered_frame = frame.filter_ranged(
817                    0.0,
818                    2000.0,
819                    (pasef_info.scan_num_begin - scan_margin) as i32,
820                    (pasef_info.scan_num_end + scan_margin) as i32,
821                    0.0,
822                    5.0,
823                    0.0,
824                    1e9,
825                    0,
826                    i32::MAX,
827                );
828
829                // Append data from this frame (merge/sum behavior)
830                combined_scan.extend(filtered_frame.scan.iter());
831                combined_mobility.extend(filtered_frame.ims_frame.mobility.iter());
832                combined_tof.extend(filtered_frame.tof.iter());
833                combined_mz.extend(filtered_frame.ims_frame.mz.iter());
834                combined_intensity.extend(filtered_frame.ims_frame.intensity.iter());
835            }
836
837            if combined_mz.is_empty() {
838                return None;
839            }
840
841            // Determine precursor m/z (prefer monoisotopic, fallback to highest intensity)
842            let precursor_mz = precursor.precursor_mz_monoisotopic
843                .unwrap_or(precursor.precursor_mz_highest_intensity);
844
845            Some(PASEFFragmentData {
846                frame_id: first_pasef.frame_id as u32,
847                precursor_id: *precursor_id as u32,
848                collision_energy: first_pasef.collision_energy,
849                scan_start_time,
850                scan: combined_scan,
851                mobility: combined_mobility,
852                tof: combined_tof,
853                mz: combined_mz,
854                intensity: combined_intensity,
855                precursor_mz,
856                precursor_charge: precursor.precursor_charge.map(|c| c as i32),
857                precursor_intensity: precursor.precursor_total_intensity,
858                isolation_mz: first_pasef.isolation_mz,
859                isolation_width: first_pasef.isolation_width,
860            })
861        };
862
863        let fragment_data: Vec<PASEFFragmentData> = if uses_bruker_sdk {
864            // Sequential processing when using Bruker SDK (not thread-safe)
865            pasef_by_precursor
866                .iter()
867                .filter_map(process_precursor)
868                .collect()
869        } else {
870            // Parallel processing when using simple index converter (thread-safe)
871            let pool = ThreadPoolBuilder::new()
872                .num_threads(num_threads)
873                .build()
874                .unwrap();
875
876            pool.install(|| {
877                pasef_by_precursor
878                    .par_iter()
879                    .filter_map(|item| process_precursor(item))
880                    .collect()
881            })
882        };
883
884        // Step 5: Process all fragments in parallel using the batch processor
885        process_pasef_fragments_batch(fragment_data, dataset_name, &config, num_threads)
886    }
887
888    pub fn sample_pasef_fragment_random(
889        &self,
890        target_scan_apex: i32,
891        experiment_max_scan: i32,
892    ) -> TimsFrame {
893        let pasef_meta = &self.pasef_meta;
894        let random_index = rand::random::<usize>() % pasef_meta.len();
895        let pasef_info = &pasef_meta[random_index];
896        
897        // get the frame
898        let frame = self.loader.get_frame(pasef_info.frame_id as u32);
899        
900        // get five percent of the scan range
901        let scan_margin = (pasef_info.scan_num_end - pasef_info.scan_num_begin) / 20;
902        
903        // get the fragment spectrum by scan range
904        let mut filtered = frame.filter_ranged(
905            0.0,
906            2000.0,
907            (pasef_info.scan_num_begin - scan_margin) as i32,
908            (pasef_info.scan_num_end + scan_margin) as i32,
909            0.0,
910            5.0,
911            0.0,
912            1e9,
913            0,
914            i32::MAX,
915        );
916
917        // Safety check
918        if filtered.scan.is_empty() {
919            return filtered;
920        }
921
922        // Compute median scan
923        let mut scan_copy = filtered.scan.clone();
924        scan_copy.sort_unstable();
925        let median_scan = scan_copy[scan_copy.len() / 2];
926
927        // Compute shift
928        let scan_shift = target_scan_apex - median_scan;
929
930        // Apply shift to scan values
931        for s in filtered.scan.iter_mut() {
932            *s += scan_shift;
933        }
934
935        // Refilter to clip shifted scans that fall outside valid bounds
936        let re_filtered = filtered.filter_ranged(
937            0.0,
938            2000.0,
939            0,
940            experiment_max_scan,
941            0.0,
942            5.0,
943            0.0,
944            1e9,
945            0,
946            i32::MAX,
947        );
948
949        re_filtered
950    }
951    
952    pub fn sample_pasef_fragments_random(
953        &self,
954        target_scan_apex_values: Vec<i32>,
955        experiment_max_scan: i32,
956    ) -> TimsFrame {
957
958        // return empty frame is target_scan_apex_values is empty
959        if target_scan_apex_values.is_empty() {
960            return TimsFrame {
961                frame_id: 0, // Replace with a suitable default value
962                ms_type: MsType::FragmentDda,
963                scan: Vec::new(),
964                tof: Vec::new(),
965                ims_frame: ImsFrame::default(), // Uses the default implementation for `ImsFrame`
966            }
967        }
968        
969        let mut pasef_frames = Vec::new();
970        
971        for target_scan_apex in target_scan_apex_values {
972            let pasef_frame = self.sample_pasef_fragment_random(target_scan_apex, experiment_max_scan);
973            pasef_frames.push(pasef_frame);
974        }
975        
976        // create combined frame by summing the frame structures, they override add
977        let mut combined_frame = pasef_frames[0].clone();
978        
979        for frame in pasef_frames.iter().skip(1) {
980            combined_frame = combined_frame + frame.clone();
981        }
982
983        // re-calculate ion mobility
984        let im_values = self.scan_to_inverse_mobility(
985            combined_frame.frame_id as u32,
986            &combined_frame.scan.iter().map(|x| *x as u32).collect(),
987        );
988
989        // Update the inverse mobility values
990        combined_frame.ims_frame.mobility = std::sync::Arc::new(im_values);
991        
992        combined_frame
993    }
994
995    pub fn sample_precursor_signal(
996        &self,
997        num_frames: usize,
998        max_intensity: f64,
999        take_probability: f64,
1000    ) -> TimsFrame {
1001        // get all precursor frames
1002        let meta_data = read_meta_data_sql(&self.loader.get_data_path()).unwrap();
1003        let precursor_frames = meta_data.iter().filter(|x| x.ms_ms_type == 0);
1004
1005        // randomly sample num_frames
1006        let mut rng = rand::thread_rng();
1007        let mut sampled_frames: Vec<TimsFrame> = Vec::new();
1008
1009        // go through each frame and sample the data
1010        for frame in precursor_frames.choose_multiple(&mut rng, num_frames) {
1011            let frame_id = frame.id;
1012            let frame_data = self
1013                .loader
1014                .get_frame(frame_id as u32)
1015                .filter_ranged(0.0, 2000.0, 0, 1000, 0.0, 5.0, 1.0, max_intensity, 0, i32::MAX)
1016                .generate_random_sample(take_probability);
1017            sampled_frames.push(frame_data);
1018        }
1019
1020        // get the first frame
1021        let mut sampled_frame = sampled_frames.remove(0);
1022
1023        // sum all the other frames to the first frame
1024        for frame in sampled_frames {
1025            sampled_frame = sampled_frame + frame;
1026        }
1027
1028        sampled_frame
1029    }
1030}
1031
1032impl TimsData for TimsDatasetDDA {
1033    fn get_frame(&self, frame_id: u32) -> TimsFrame {
1034        self.loader.get_frame(frame_id)
1035    }
1036
1037    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
1038        self.loader.get_raw_frame(frame_id)
1039    }
1040
1041    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1042        self.loader.get_slice(frame_ids, num_threads)
1043    }
1044
1045    fn get_acquisition_mode(&self) -> AcquisitionMode {
1046        self.loader.get_acquisition_mode().clone()
1047    }
1048
1049    fn get_frame_count(&self) -> i32 {
1050        self.loader.get_frame_count()
1051    }
1052
1053    fn get_data_path(&self) -> &str {
1054        &self.loader.get_data_path()
1055    }
1056}
1057
1058impl IndexConverter for TimsDatasetDDA {
1059    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1060        self.loader
1061            .get_index_converter()
1062            .tof_to_mz(frame_id, tof_values)
1063    }
1064
1065    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1066        self.loader
1067            .get_index_converter()
1068            .mz_to_tof(frame_id, mz_values)
1069    }
1070
1071    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1072        self.loader
1073            .get_index_converter()
1074            .scan_to_inverse_mobility(frame_id, scan_values)
1075    }
1076
1077    fn inverse_mobility_to_scan(
1078        &self,
1079        frame_id: u32,
1080        inverse_mobility_values: &Vec<f64>,
1081    ) -> Vec<u32> {
1082        self.loader
1083            .get_index_converter()
1084            .inverse_mobility_to_scan(frame_id, inverse_mobility_values)
1085    }
1086}