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        // The Bruker SDK's index conversion is not safe to call concurrently on one handle, but
687        // the SDK-free formula converter evaluates the very calibration curves the file carries
688        // (matched the SDK to 3e-11 Th / 1 ULP on the reference data) and *is* `Sync`. So in SDK
689        // mode we read through that instead of dropping to a single thread; only a file without
690        // usable calibration tables still forces the serial path.
691        let sdk_free = self.loader.sdk_free_converter();
692        let conv: Option<&(dyn IndexConverter + Sync)> =
693            sdk_free.as_ref().map(|c| c as &(dyn IndexConverter + Sync));
694        let read_frame = |frame_id: u32| match conv {
695            Some(c) => self.loader.get_frame_with_converter(frame_id, c),
696            None => self.loader.get_frame(frame_id),
697        };
698        let force_serial = self.loader.uses_bruker_sdk() && conv.is_none();
699
700        // Helper closure to process a single PASEF fragment
701        let process_fragment = |pasef_info: &PasefMsMsMeta| -> PASEFDDAFragment {
702            // get the frame
703            let frame = read_frame(pasef_info.frame_id as u32);
704
705            // get five percent of the scan range
706            let scan_margin = (pasef_info.scan_num_end - pasef_info.scan_num_begin) / 20;
707
708            // get the fragment spectrum by scan range
709            let filtered_frame = frame.filter_ranged(
710                0.0,
711                2000.0,
712                (pasef_info.scan_num_begin - scan_margin) as i32,
713                (pasef_info.scan_num_end + scan_margin) as i32,
714                0.0,
715                5.0,
716                0.0,
717                1e9,
718                0,
719                i32::MAX,
720            );
721
722            PASEFDDAFragment {
723                frame_id: pasef_info.frame_id as u32,
724                precursor_id: pasef_info.precursor_id as u32,
725                collision_energy: pasef_info.collision_energy,
726                // flatten the spectrum
727                selected_fragment: filtered_frame,
728            }
729        };
730
731        if force_serial {
732            // No thread-safe converter available: fall back to one thread.
733            filtered_pasef_info.iter().map(|info| process_fragment(info)).collect()
734        } else {
735            // Parallel processing when using simple index converter (thread-safe)
736            let pool = ThreadPoolBuilder::new()
737                .num_threads(num_threads)
738                .build()
739                .unwrap();
740
741            pool.install(|| {
742                filtered_pasef_info.par_iter().map(|info| process_fragment(info)).collect()
743            })
744        }
745    }
746
747    /// Get preprocessed PASEF fragments ready for database search.
748    /// This method performs parallel processing of all fragment spectra, including:
749    /// - Flattening frames across ion mobility dimension
750    /// - Deisotoping (optional)
751    /// - Filtering to top N peaks
752    /// - Computing inverse mobility along scan marginal
753    ///
754    /// # Arguments
755    /// * `dataset_name` - Name of the dataset for generating spec_ids
756    /// * `config` - Spectrum processing configuration
757    /// * `num_threads` - Number of threads to use for parallel processing
758    ///
759    /// # Returns
760    /// Vector of preprocessed spectra ready for Sage search
761    pub fn get_preprocessed_pasef_fragments(
762        &self,
763        dataset_name: &str,
764        config: SpectrumProcessingConfig,
765        num_threads: usize,
766    ) -> Vec<PreprocessedSpectrum> {
767        // Step 1: Get raw PASEF fragments info
768        let pasef_info = self.get_pasef_frame_ms_ms_info();
769
770        // Step 2: Get precursor metadata
771        let precursor_meta = read_dda_precursor_meta(&self.loader.get_data_path()).unwrap_or_default();
772        let frame_meta = read_meta_data_sql(&self.loader.get_data_path()).unwrap_or_default();
773
774        // Create lookup maps
775        let precursor_map: BTreeMap<i64, &DDAPrecursorMeta> = precursor_meta
776            .iter()
777            .map(|p| (p.precursor_id, p))
778            .collect();
779
780        let frame_time_map: BTreeMap<i64, f64> = frame_meta
781            .iter()
782            .map(|f| (f.id, f.time / 60.0))  // Convert to minutes
783            .collect();
784
785        // Step 3: Group PASEF info by precursor_id to aggregate re-fragmented precursors
786        // This matches Python's groupby('precursor_id').agg({'raw_data': 'sum', ...})
787        let mut pasef_by_precursor: BTreeMap<i64, Vec<&PasefMsMsMeta>> = BTreeMap::new();
788        for info in &pasef_info {
789            pasef_by_precursor
790                .entry(info.precursor_id)
791                .or_insert_with(Vec::new)
792                .push(info);
793        }
794
795        // Step 4: Build fragment data with aggregation (merge frames for same precursor)
796        // The Bruker SDK's index conversion is not safe to call concurrently on one handle, but
797        // the SDK-free formula converter evaluates the very calibration curves the file carries
798        // (matched the SDK to 3e-11 Th / 1 ULP on the reference data) and *is* `Sync`. So in SDK
799        // mode we read through that instead of dropping to a single thread; only a file without
800        // usable calibration tables still forces the serial path.
801        let sdk_free = self.loader.sdk_free_converter();
802        let conv: Option<&(dyn IndexConverter + Sync)> =
803            sdk_free.as_ref().map(|c| c as &(dyn IndexConverter + Sync));
804        let read_frame = |frame_id: u32| match conv {
805            Some(c) => self.loader.get_frame_with_converter(frame_id, c),
806            None => self.loader.get_frame(frame_id),
807        };
808        let force_serial = self.loader.uses_bruker_sdk() && conv.is_none();
809
810        // Helper closure to process a single precursor
811        let process_precursor = |(precursor_id, pasef_infos): (&i64, &Vec<&PasefMsMsMeta>)| -> Option<PASEFFragmentData> {
812            // Get precursor metadata
813            let precursor = precursor_map.get(precursor_id)?;
814
815            // Use first PASEF info for metadata (matches Python's 'first')
816            let first_pasef = pasef_infos.first()?;
817
818            // Get retention time from first frame
819            let scan_start_time = frame_time_map.get(&first_pasef.frame_id).copied().unwrap_or(0.0);
820
821            // Collect and merge all frames for this precursor (matches Python's 'sum')
822            let mut combined_scan = Vec::new();
823            let mut combined_mobility = Vec::new();
824            let mut combined_tof = Vec::new();
825            let mut combined_mz = Vec::new();
826            let mut combined_intensity = Vec::new();
827
828            for pasef_info in pasef_infos {
829                // Get the frame and filter by scan range
830                let frame = read_frame(pasef_info.frame_id as u32);
831
832                // Get five percent of the scan range for margin
833                let scan_margin = (pasef_info.scan_num_end - pasef_info.scan_num_begin) / 20;
834
835                // Filter frame by scan range
836                let filtered_frame = frame.filter_ranged(
837                    0.0,
838                    2000.0,
839                    (pasef_info.scan_num_begin - scan_margin) as i32,
840                    (pasef_info.scan_num_end + scan_margin) as i32,
841                    0.0,
842                    5.0,
843                    0.0,
844                    1e9,
845                    0,
846                    i32::MAX,
847                );
848
849                // Append data from this frame (merge/sum behavior)
850                combined_scan.extend(filtered_frame.scan.iter());
851                combined_mobility.extend(filtered_frame.ims_frame.mobility.iter());
852                combined_tof.extend(filtered_frame.tof.iter());
853                combined_mz.extend(filtered_frame.ims_frame.mz.iter());
854                combined_intensity.extend(filtered_frame.ims_frame.intensity.iter());
855            }
856
857            if combined_mz.is_empty() {
858                return None;
859            }
860
861            // Determine precursor m/z (prefer monoisotopic, fallback to highest intensity)
862            let precursor_mz = precursor.precursor_mz_monoisotopic
863                .unwrap_or(precursor.precursor_mz_highest_intensity);
864
865            Some(PASEFFragmentData {
866                frame_id: first_pasef.frame_id as u32,
867                precursor_id: *precursor_id as u32,
868                collision_energy: first_pasef.collision_energy,
869                scan_start_time,
870                scan: combined_scan,
871                mobility: combined_mobility,
872                tof: combined_tof,
873                mz: combined_mz,
874                intensity: combined_intensity,
875                precursor_mz,
876                precursor_charge: precursor.precursor_charge.map(|c| c as i32),
877                precursor_intensity: precursor.precursor_total_intensity,
878                isolation_mz: first_pasef.isolation_mz,
879                isolation_width: first_pasef.isolation_width,
880            })
881        };
882
883        let fragment_data: Vec<PASEFFragmentData> = if force_serial {
884            // No thread-safe converter available: fall back to one thread.
885            pasef_by_precursor
886                .iter()
887                .filter_map(process_precursor)
888                .collect()
889        } else {
890            // Parallel processing when using simple index converter (thread-safe)
891            let pool = ThreadPoolBuilder::new()
892                .num_threads(num_threads)
893                .build()
894                .unwrap();
895
896            pool.install(|| {
897                pasef_by_precursor
898                    .par_iter()
899                    .filter_map(|item| process_precursor(item))
900                    .collect()
901            })
902        };
903
904        // Step 5: Process all fragments in parallel using the batch processor
905        process_pasef_fragments_batch(fragment_data, dataset_name, &config, num_threads)
906    }
907
908    pub fn sample_pasef_fragment_random(
909        &self,
910        target_scan_apex: i32,
911        experiment_max_scan: i32,
912    ) -> TimsFrame {
913        let pasef_meta = &self.pasef_meta;
914        let random_index = rand::random::<usize>() % pasef_meta.len();
915        let pasef_info = &pasef_meta[random_index];
916        
917        // get the frame
918        let frame = self.loader.get_frame(pasef_info.frame_id as u32);
919        
920        // get five percent of the scan range
921        let scan_margin = (pasef_info.scan_num_end - pasef_info.scan_num_begin) / 20;
922        
923        // get the fragment spectrum by scan range
924        let mut filtered = frame.filter_ranged(
925            0.0,
926            2000.0,
927            (pasef_info.scan_num_begin - scan_margin) as i32,
928            (pasef_info.scan_num_end + scan_margin) as i32,
929            0.0,
930            5.0,
931            0.0,
932            1e9,
933            0,
934            i32::MAX,
935        );
936
937        // Safety check
938        if filtered.scan.is_empty() {
939            return filtered;
940        }
941
942        // Compute median scan
943        let mut scan_copy = filtered.scan.clone();
944        scan_copy.sort_unstable();
945        let median_scan = scan_copy[scan_copy.len() / 2];
946
947        // Compute shift
948        let scan_shift = target_scan_apex - median_scan;
949
950        // Apply shift to scan values
951        for s in filtered.scan.iter_mut() {
952            *s += scan_shift;
953        }
954
955        // Refilter to clip shifted scans that fall outside valid bounds
956        let re_filtered = filtered.filter_ranged(
957            0.0,
958            2000.0,
959            0,
960            experiment_max_scan,
961            0.0,
962            5.0,
963            0.0,
964            1e9,
965            0,
966            i32::MAX,
967        );
968
969        re_filtered
970    }
971    
972    pub fn sample_pasef_fragments_random(
973        &self,
974        target_scan_apex_values: Vec<i32>,
975        experiment_max_scan: i32,
976    ) -> TimsFrame {
977
978        // return empty frame is target_scan_apex_values is empty
979        if target_scan_apex_values.is_empty() {
980            return TimsFrame {
981                frame_id: 0, // Replace with a suitable default value
982                ms_type: MsType::FragmentDda,
983                scan: Vec::new(),
984                tof: Vec::new(),
985                ims_frame: ImsFrame::default(), // Uses the default implementation for `ImsFrame`
986            }
987        }
988        
989        let mut pasef_frames = Vec::new();
990        
991        for target_scan_apex in target_scan_apex_values {
992            let pasef_frame = self.sample_pasef_fragment_random(target_scan_apex, experiment_max_scan);
993            pasef_frames.push(pasef_frame);
994        }
995        
996        // create combined frame by summing the frame structures, they override add
997        let mut combined_frame = pasef_frames[0].clone();
998        
999        for frame in pasef_frames.iter().skip(1) {
1000            combined_frame = combined_frame + frame.clone();
1001        }
1002
1003        // re-calculate ion mobility
1004        let im_values = self.scan_to_inverse_mobility(
1005            combined_frame.frame_id as u32,
1006            &combined_frame.scan.iter().map(|x| *x as u32).collect(),
1007        );
1008
1009        // Update the inverse mobility values
1010        combined_frame.ims_frame.mobility = std::sync::Arc::new(im_values);
1011        
1012        combined_frame
1013    }
1014
1015    pub fn sample_precursor_signal(
1016        &self,
1017        num_frames: usize,
1018        max_intensity: f64,
1019        take_probability: f64,
1020    ) -> TimsFrame {
1021        // get all precursor frames
1022        let meta_data = read_meta_data_sql(&self.loader.get_data_path()).unwrap();
1023        let precursor_frames = meta_data.iter().filter(|x| x.ms_ms_type == 0);
1024
1025        // randomly sample num_frames
1026        let mut rng = rand::thread_rng();
1027        let mut sampled_frames: Vec<TimsFrame> = Vec::new();
1028
1029        // go through each frame and sample the data
1030        for frame in precursor_frames.choose_multiple(&mut rng, num_frames) {
1031            let frame_id = frame.id;
1032            let frame_data = self
1033                .loader
1034                .get_frame(frame_id as u32)
1035                .filter_ranged(0.0, 2000.0, 0, 1000, 0.0, 5.0, 1.0, max_intensity, 0, i32::MAX)
1036                .generate_random_sample(take_probability);
1037            sampled_frames.push(frame_data);
1038        }
1039
1040        // get the first frame
1041        let mut sampled_frame = sampled_frames.remove(0);
1042
1043        // sum all the other frames to the first frame
1044        for frame in sampled_frames {
1045            sampled_frame = sampled_frame + frame;
1046        }
1047
1048        sampled_frame
1049    }
1050}
1051
1052impl TimsData for TimsDatasetDDA {
1053    fn get_frame(&self, frame_id: u32) -> TimsFrame {
1054        self.loader.get_frame(frame_id)
1055    }
1056
1057    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
1058        self.loader.get_raw_frame(frame_id)
1059    }
1060
1061    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1062        self.loader.get_slice(frame_ids, num_threads)
1063    }
1064
1065    fn get_acquisition_mode(&self) -> AcquisitionMode {
1066        self.loader.get_acquisition_mode().clone()
1067    }
1068
1069    fn get_frame_count(&self) -> i32 {
1070        self.loader.get_frame_count()
1071    }
1072
1073    fn get_data_path(&self) -> &str {
1074        &self.loader.get_data_path()
1075    }
1076}
1077
1078impl IndexConverter for TimsDatasetDDA {
1079    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1080        self.loader
1081            .get_index_converter()
1082            .tof_to_mz(frame_id, tof_values)
1083    }
1084
1085    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1086        self.loader
1087            .get_index_converter()
1088            .mz_to_tof(frame_id, mz_values)
1089    }
1090
1091    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1092        self.loader
1093            .get_index_converter()
1094            .scan_to_inverse_mobility(frame_id, scan_values)
1095    }
1096
1097    fn inverse_mobility_to_scan(
1098        &self,
1099        frame_id: u32,
1100        inverse_mobility_values: &Vec<f64>,
1101    ) -> Vec<u32> {
1102        self.loader
1103            .get_index_converter()
1104            .inverse_mobility_to_scan(frame_id, inverse_mobility_values)
1105    }
1106}