Skip to main content

rustdf/data/
dia.rs

1use std::sync::Arc;
2use crate::data::acquisition::AcquisitionMode;
3use rustc_hash::FxHashMap;
4use crate::data::handle::{IndexConverter, TimsData, TimsDataLoader};
5use crate::data::meta::{
6    read_dia_ms_ms_info, read_dia_ms_ms_windows, read_global_meta_sql, read_meta_data_sql,
7    DiaMsMisInfo, DiaMsMsWindow, FrameMeta, GlobalMetaData,
8};
9use mscore::data::spectrum::MsType;
10use mscore::timstof::frame::{RawTimsFrame, TimsFrame};
11use mscore::timstof::slice::TimsSlice;
12use rayon::iter::IntoParallelRefIterator;
13use crate::cluster::peak::{build_frame_bin_view, build_tof_rt_grid_full, expand_many_im_peaks_along_rt, FrameBinView, ImPeak1D, RtExpandParams, RtFrames, TofRtGrid};
14use crate::cluster::utility::{TofScale};
15use crate::data::utility::merge_ranges;
16use rayon::prelude::*;
17use std::collections::{HashMap};
18use rayon::ThreadPoolBuilder;
19use crate::cluster::candidates::{build_pseudo_spectra_all_pairs, build_pseudo_spectra_end_to_end, build_pseudo_spectra_end_to_end_xic, PseudoBuildResult, ScoreOpts};
20use crate::cluster::cluster::{attach_raw_points_for_spec_1d_in_ctx, bin_range_for_win, build_scan_slices, decorate_with_mz_for_cluster, evaluate_spec_1d, make_specs_from_im_and_rt_groups_threads, BuildSpecOpts, ClusterResult1D, ClusterSpec1D, Eval1DOpts, RawAttachContext, RawPoints, ScanSlice};
21use crate::cluster::feature::SimpleFeature;
22use crate::cluster::pseudo::{PseudoSpecOpts};
23use crate::cluster::candidates::CandidateOpts;
24use crate::cluster::scoring::XicScoreOpts;
25
26// ==========================================================
27// Options structs for reducing parameter explosion (Phase 5)
28// ==========================================================
29
30/// Options for cluster extraction from IM peaks.
31#[derive(Clone, Debug)]
32pub struct ClusterExtractionOpts {
33    /// TOF step for grid binning (typically 1, 2, 4, 8).
34    pub tof_step: i32,
35    /// RT expansion parameters.
36    pub rt_params: RtExpandParams,
37    /// Spec building options (window padding, ms_level).
38    pub build_opts: BuildSpecOpts,
39    /// Evaluation options (refine, attach axes/traces).
40    pub eval_opts: Eval1DOpts,
41    /// Whether to require RT overlap for spec generation.
42    pub require_rt_overlap: bool,
43    /// Number of threads for parallel processing.
44    pub num_threads: usize,
45}
46
47impl Default for ClusterExtractionOpts {
48    fn default() -> Self {
49        Self {
50            tof_step: 8,
51            rt_params: RtExpandParams::default(),
52            build_opts: BuildSpecOpts::ms1_defaults(),
53            eval_opts: Eval1DOpts::default(),
54            require_rt_overlap: true,
55            num_threads: 4,
56        }
57    }
58}
59
60impl ClusterExtractionOpts {
61    /// Create options for MS1 (precursor) extraction.
62    pub fn for_ms1() -> Self {
63        Self {
64            build_opts: BuildSpecOpts::ms1_defaults(),
65            ..Self::default()
66        }
67    }
68
69    /// Create options for MS2 (fragment) extraction.
70    pub fn for_ms2() -> Self {
71        Self {
72            build_opts: BuildSpecOpts::ms2_defaults(),
73            ..Self::default()
74        }
75    }
76
77    /// Set the TOF step.
78    pub fn with_tof_step(mut self, step: i32) -> Self {
79        self.tof_step = step;
80        self
81    }
82
83    /// Set the number of threads.
84    pub fn with_threads(mut self, n: usize) -> Self {
85        self.num_threads = n;
86        self
87    }
88}
89
90/// Options for building pseudo spectra from clusters.
91#[derive(Clone, Debug)]
92pub struct PseudoSpectrumBuildOpts<'a> {
93    /// Candidate enumeration options.
94    pub cand_opts: &'a CandidateOpts,
95    /// Geometric scoring options.
96    pub score_opts: &'a ScoreOpts,
97    /// Pseudo spectrum construction options.
98    pub pseudo_opts: &'a PseudoSpecOpts,
99    /// Optional features for grouping.
100    pub features: Option<&'a [SimpleFeature]>,
101}
102
103#[derive(Clone, Debug)]
104pub struct ProgramSlice {
105    pub mz_lo: f64,
106    pub mz_hi: f64,
107    pub scan_lo: u32,
108    pub scan_hi: u32,
109}
110
111#[inline]
112fn ranges_overlap_u32(a: (u32, u32), b: (u32, u32)) -> bool {
113    let lo = a.0.max(b.0);
114    let hi = a.1.min(b.1);
115    hi >= lo
116}
117
118#[derive(Debug, Clone)]
119pub struct Ms2GroupProgram {
120    /// Raw per-row isolation windows (normalized, ascending, finite)
121    pub mz_windows: Vec<(f32, f32)>,
122    /// Raw per-row active scan ranges (normalized, inclusive)
123    pub scan_ranges: Vec<(u32, u32)>,
124    /// Union (convex hull) over all mz_windows for the group; None if no valid rows
125    pub mz_union: Option<(f32, f32)>,
126    /// Merged disjoint scan ranges (inclusive) over the group; empty = permissive fallback
127    pub scan_unions: Vec<(u32, u32)>,
128}
129
130#[derive(Debug, Clone)]
131pub struct DiaIndex {
132    /// MS2 frame_id -> window_group
133    pub frame_to_group: HashMap<u32, u32>,
134    /// window_group -> MS2 frame_ids (sorted by **time**)
135    pub group_to_frames: HashMap<u32, Vec<u32>>,
136    /// window_group -> list of (mz_lo, mz_hi) isolation ranges (normalized)
137    pub group_to_isolation: HashMap<u32, Vec<(f64, f64)>>,
138    /// window_group -> list of (scan_lo, scan_hi) active scan ranges (normalized, inclusive)
139    pub group_to_scan_ranges: HashMap<u32, Vec<(u32, u32)>>,
140    /// window_group -> convex m/z union (lo, hi)
141    pub group_to_mz_union: HashMap<u32, (f64, f64)>,
142    /// window_group -> merged disjoint scan unions (inclusive)
143    pub group_to_scan_unions: HashMap<u32, Vec<(u32, u32)>>,
144    /// frame_id -> time (seconds)
145    pub frame_time: HashMap<u32, f64>,
146    /// New: pre-materialized program slices per group.
147    pub group_to_slices: HashMap<u32, Arc<[ProgramSlice]>>,
148}
149
150impl DiaIndex {
151    pub fn new(meta: &[FrameMeta], info: &[DiaMsMisInfo], wins: &[DiaMsMsWindow]) -> Self {
152        // helpers
153        #[inline]
154        fn norm_f64_pair(a: f64, b: f64) -> Option<(f64, f64)> {
155            if !a.is_finite() || !b.is_finite() { return None; }
156            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
157            if hi > lo { Some((lo, hi)) } else { None }
158        }
159        #[inline]
160        fn norm_u32_pair(a: u32, b: u32) -> Option<(u32, u32)> {
161            let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
162            Some((lo, hi))
163        }
164        fn merge_scan_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
165            if ranges.is_empty() { return ranges; }
166            ranges.sort_unstable_by_key(|&(l, r)| (l, r));
167            let mut out: Vec<(u32,u32)> = Vec::with_capacity(ranges.len());
168            let mut cur = ranges[0];
169            for &(l, r) in &ranges[1..] {
170                if l <= cur.1.saturating_add(1) {
171                    if r > cur.1 { cur.1 = r; }
172                } else {
173                    out.push(cur);
174                    cur = (l, r);
175                }
176            }
177            out.push(cur);
178            out
179        }
180
181        // frame_id -> time
182        let mut frame_time: HashMap<u32, f64> = HashMap::new();
183        for m in meta {
184            frame_time.insert(m.id as u32, m.time);
185        }
186
187        // frame_id -> group; group -> frames
188        let mut frame_to_group: HashMap<u32, u32> = HashMap::new();
189        let mut group_to_frames: HashMap<u32, Vec<u32>> = HashMap::new();
190        for r in info {
191            let fid = r.frame_id;
192            frame_to_group.insert(fid, r.window_group);
193            group_to_frames.entry(r.window_group).or_default().push(fid);
194        }
195
196        // raw program rows
197        let mut group_to_isolation: HashMap<u32, Vec<(f64, f64)>> = HashMap::new();
198        let mut group_to_scan_ranges: HashMap<u32, Vec<(u32, u32)>> = HashMap::new();
199        for w in wins {
200            let half = 0.5 * w.isolation_width;
201            if half.is_finite() && half > 0.0 && w.isolation_mz.is_finite() {
202                let lo = w.isolation_mz - half;
203                let hi = w.isolation_mz + half;
204                if let Some(p) = norm_f64_pair(lo, hi) {
205                    group_to_isolation.entry(w.window_group).or_default().push(p);
206                }
207            }
208            if let Some(p) = norm_u32_pair(w.scan_num_begin, w.scan_num_end) {
209                group_to_scan_ranges.entry(w.window_group).or_default().push(p);
210            }
211        }
212
213        // unions + sort frames by time
214        let mut group_to_mz_union: HashMap<u32, (f64, f64)> = HashMap::new();
215        let mut group_to_scan_unions: HashMap<u32, Vec<(u32, u32)>> = HashMap::new();
216
217        for (g, frames) in group_to_frames.iter_mut() {
218            frames.sort_unstable_by(|&a, &b| {
219                let ta = frame_time.get(&a).copied().unwrap_or(f64::NAN);
220                let tb = frame_time.get(&b).copied().unwrap_or(f64::NAN);
221                ta.partial_cmp(&tb).unwrap_or(std::cmp::Ordering::Equal)
222            });
223
224            if let Some(v) = group_to_isolation.get(g) {
225                if !v.is_empty() {
226                    let lo = v.iter().map(|p| p.0).fold(f64::INFINITY, f64::min);
227                    let hi = v.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
228                    if lo.is_finite() && hi.is_finite() && hi > lo {
229                        group_to_mz_union.insert(*g, (lo, hi));
230                    }
231                }
232            }
233            let merged = merge_scan_ranges(group_to_scan_ranges.get(g).cloned().unwrap_or_default());
234            group_to_scan_unions.insert(*g, merged);
235        }
236
237        let mut group_to_slices: HashMap<u32, Arc<[ProgramSlice]>> = HashMap::new();
238
239        for (&g, iso_rows) in &group_to_isolation {
240            let scan_rows = group_to_scan_ranges.get(&g).cloned().unwrap_or_default();
241            let n = iso_rows.len().min(scan_rows.len());
242            let mut v = Vec::with_capacity(n);
243            for k in 0..n {
244                let (mz_lo, mz_hi)     = iso_rows[k];
245                let (scan_lo, scan_hi) = scan_rows[k];
246                v.push(ProgramSlice { mz_lo, mz_hi, scan_lo, scan_hi });
247            }
248            group_to_slices.insert(g, v.into());
249        }
250
251        DiaIndex {
252            frame_to_group,
253            group_to_frames,
254            group_to_isolation,
255            group_to_scan_ranges,
256            group_to_mz_union,
257            group_to_scan_unions,
258            frame_time,
259            group_to_slices,
260        }
261    }
262
263    pub fn program_slices_for_group(&self, g: u32) -> Vec<ProgramSlice> {
264        self.slices_for_group(g).to_vec()
265    }
266
267    /// Tiles in group `g` that could have selected this precursor.
268    ///
269    /// Conditions:
270    ///   1) Precursor IM window overlaps tile scan band.
271    ///   2) Precursor m/z lies inside tile's isolation m/z window.
272    /// Tiles in group `g` that could have selected this precursor.
273    ///
274    /// Conditions:
275    ///   1) Precursor **apex** IM scan lies inside tile scan band.
276    ///   2) Precursor m/z lies inside tile's isolation m/z window.
277    pub fn tiles_for_precursor_in_group(
278        &self,
279        g: u32,
280        prec_mz: f32,
281        im_apex: f32,
282    ) -> Vec<usize> {
283        let slices = self.slices_for_group(g);
284        let mut hits = Vec::new();
285
286        if !prec_mz.is_finite() || !im_apex.is_finite() {
287            return hits;
288        }
289
290        for (idx, s) in slices.iter().enumerate() {
291            // Precursor m/z inside isolation window
292            if prec_mz < s.mz_lo as f32 || prec_mz > s.mz_hi as f32 {
293                continue;
294            }
295
296            // Apex inside scan band (no margin for now, same semantics as your debug plot)
297            let scan_lo = s.scan_lo as f32;
298            let scan_hi = s.scan_hi as f32;
299            if im_apex < scan_lo || im_apex > scan_hi {
300                continue;
301            }
302
303            hits.push(idx);
304        }
305
306        hits
307    }
308
309    /// Tiles in group `g` that an MS2 fragment cluster could belong to.
310    ///
311    /// IMPORTANT:
312    ///   Fragment m/z is *not* constrained by isolation window (selection
313    ///   happened before fragmentation). We only check the IM/scan range.
314    pub fn tiles_for_fragment_in_group(
315        &self,
316        g: u32,
317        im_window: (usize, usize),
318    ) -> Vec<usize> {
319        let slices = self.program_slices_for_group(g);
320        let mut hits = Vec::new();
321
322        for (idx, s) in slices.iter().enumerate() {
323            let tile_scans = (s.scan_lo, s.scan_hi);
324
325            if ranges_overlap_u32(
326                (im_window.0 as u32, im_window.1 as u32),
327                tile_scans,
328            ) {
329                hits.push(idx);
330            }
331        }
332
333        hits
334    }
335
336    /// Convenience: materialize a program description for a group.
337    pub fn program_for_group(&self, g: u32) -> Ms2GroupProgram {
338        let mz_windows = self.group_to_isolation
339            .get(&g)
340            .map(|v| v.iter().map(|&(a,b)| (a as f32, b as f32)).collect())
341            .unwrap_or_else(Vec::new);
342
343        let scan_ranges = self.group_to_scan_ranges
344            .get(&g)
345            .cloned()
346            .unwrap_or_else(Vec::new);
347
348        let mz_union = self.group_to_mz_union
349            .get(&g)
350            .map(|&(a,b)| (a as f32, b as f32));
351
352        let scan_unions = self.group_to_scan_unions
353            .get(&g)
354            .cloned()
355            .unwrap_or_else(Vec::new);
356
357        Ms2GroupProgram { mz_windows, scan_ranges, mz_union, scan_unions }
358    }
359
360    pub fn groups_for_precursor(
361        &self,
362        prec_mz: f32,
363        im_apex: f32,
364    ) -> Vec<u32> {
365        if !prec_mz.is_finite() || !im_apex.is_finite() {
366            return Vec::new();
367        }
368
369        let mut out = Vec::new();
370        for (&g, &(mz_lo, mz_hi)) in &self.group_to_mz_union {
371            if prec_mz < mz_lo as f32 || prec_mz > mz_hi as f32 {
372                continue;
373            }
374
375            // optionally also check scan_unions
376            if let Some(unions) = self.group_to_scan_unions.get(&g) {
377
378                if !unions.iter().any(|&(lo, hi)| {
379                    (im_apex as u32) >= lo && (im_apex as u32) <= hi
380                }) {
381                    continue;
382                }
383            }
384
385            let tiles = self.tiles_for_precursor_in_group(g, prec_mz, im_apex);
386            if !tiles.is_empty() {
387                out.push(g);
388            }
389        }
390        out
391    }
392
393    /// Return the m/z isolation bounds (lo, hi) for a given tile index in a group.
394    ///
395    /// If the tile index is out of range for this group, returns (NaN, NaN).
396    /// This is used by the fragment query to decide whether a fragment's m/z
397    /// lies **inside** the precursor-selection band of the shared tile.
398    pub fn tile_mz_bounds(&self, g: u32, tile_idx: usize) -> (f32, f32) {
399        let slices = self.slices_for_group(g);
400        if tile_idx >= slices.len() {
401            return (f32::NAN, f32::NAN);
402        }
403        let s = &slices[tile_idx];
404        (s.mz_lo as f32, s.mz_hi as f32)
405    }
406
407    #[inline]
408    pub fn mz_bounds_for_window_group_core(&self, g: u32) -> Option<(f32, f32)> {
409        self.group_to_mz_union.get(&g).map(|&(a,b)| (a as f32, b as f32))
410    }
411    #[inline]
412    pub fn scan_unions_for_window_group_core(&self, g: u32) -> Option<Vec<(usize, usize)>> {
413        self.group_to_scan_unions.get(&g).map(|v| v.iter().map(|&(l,r)| (l as usize, r as usize)).collect())
414    }
415
416    fn slices_for_group(&self, g: u32) -> &[ProgramSlice] {
417        self.group_to_slices.get(&g).map(|a| &a[..]).unwrap_or(&[])
418    }
419
420}
421
422pub struct TimsDatasetDIA {
423    pub loader: TimsDataLoader,
424    pub global_meta_data: GlobalMetaData,
425    pub meta_data: Vec<FrameMeta>,
426    pub dia_ms_ms_info: Vec<DiaMsMisInfo>,
427    pub dia_ms_ms_windows: Vec<DiaMsMsWindow>,
428    pub dia_index: DiaIndex,
429}
430
431impl TimsDatasetDIA {
432    pub fn new(
433        bruker_lib_path: &str,
434        data_path: &str,
435        in_memory: bool,
436        use_bruker_sdk: bool,
437    ) -> Self {
438        // TODO: error handling
439        let global_meta_data = read_global_meta_sql(data_path).unwrap();
440        let meta_data = read_meta_data_sql(data_path).unwrap();
441        let dia_ms_mis_info = read_dia_ms_ms_info(data_path).unwrap();
442        let dia_ms_ms_windows = read_dia_ms_ms_windows(data_path).unwrap();
443
444        let scan_max_index = meta_data.iter().map(|x| x.num_scans).max().unwrap() as u32;
445        let im_lower = global_meta_data.one_over_k0_range_lower;
446        let im_upper = global_meta_data.one_over_k0_range_upper;
447
448        let tof_max_index = global_meta_data.tof_max_index;
449        let mz_lower = global_meta_data.mz_acquisition_range_lower;
450        let mz_upper = global_meta_data.mz_acquisition_range_upper;
451
452        let loader = match in_memory {
453            true => TimsDataLoader::new_in_memory(
454                bruker_lib_path,
455                data_path,
456                use_bruker_sdk,
457                scan_max_index,
458                im_lower,
459                im_upper,
460                tof_max_index,
461                mz_lower,
462                mz_upper,
463            ),
464            false => TimsDataLoader::new_lazy(
465                bruker_lib_path,
466                data_path,
467                use_bruker_sdk,
468                scan_max_index,
469                im_lower,
470                im_upper,
471                tof_max_index,
472                mz_lower,
473                mz_upper,
474            ),
475        };
476
477        let dia_index = DiaIndex::new(&meta_data, &dia_ms_mis_info, &dia_ms_ms_windows);
478
479        TimsDatasetDIA {
480            loader,
481            global_meta_data,
482            meta_data,
483            dia_ms_ms_info: dia_ms_mis_info,
484            dia_ms_ms_windows,
485            dia_index,
486        }
487    }
488
489    /// Create a DIA dataset using the exact SDK-free Bruker calibration formulas.
490    ///
491    /// See [`crate::data::calibration`]: reads the `MzCalibration` /
492    /// `TimsCalibration` tables and needs no Bruker SDK at build or runtime.
493    /// 1/K0 is machine-exact; m/z is bit-exact for MzCalibration ModelType 1 and
494    /// accurate to a few ppm for ModelType 2.
495    pub fn new_with_bruker_formula(
496        data_path: &str,
497        in_memory: bool,
498        calibration_frame_id: u32,
499    ) -> Self {
500        let meta_data = read_meta_data_sql(data_path).unwrap();
501        let global_meta_data = read_global_meta_sql(data_path).unwrap();
502        let dia_ms_mis_info = read_dia_ms_ms_info(data_path).unwrap();
503        let dia_ms_ms_windows = read_dia_ms_ms_windows(data_path).unwrap();
504
505        let loader = match in_memory {
506            true => TimsDataLoader::new_in_memory_with_bruker_formula(
507                data_path,
508                calibration_frame_id,
509            ),
510            false => {
511                TimsDataLoader::new_lazy_with_bruker_formula(data_path, calibration_frame_id)
512            }
513        };
514
515        let dia_index = DiaIndex::new(&meta_data, &dia_ms_mis_info, &dia_ms_ms_windows);
516
517        TimsDatasetDIA {
518            loader,
519            global_meta_data,
520            meta_data,
521            dia_ms_ms_info: dia_ms_mis_info,
522            dia_ms_ms_windows,
523            dia_index,
524        }
525    }
526
527    /// Create a DIA dataset with regression-derived m/z calibration.
528    ///
529    /// This method uses externally-provided m/z calibration coefficients (e.g., from
530    /// linear regression on SDK data) instead of the simple boundary model.
531    pub fn new_with_mz_calibration(
532        data_path: &str,
533        in_memory: bool,
534        tof_intercept: f64,
535        tof_slope: f64,
536    ) -> Self {
537        let meta_data = read_meta_data_sql(data_path).unwrap();
538        let global_meta_data = read_global_meta_sql(data_path).unwrap();
539        let dia_ms_mis_info = read_dia_ms_ms_info(data_path).unwrap();
540        let dia_ms_ms_windows = read_dia_ms_ms_windows(data_path).unwrap();
541
542        let scan_max_index = meta_data.iter().map(|x| x.num_scans).max().unwrap() as u32;
543        let im_lower = global_meta_data.one_over_k0_range_lower;
544        let im_upper = global_meta_data.one_over_k0_range_upper;
545
546        let loader = match in_memory {
547            true => TimsDataLoader::new_in_memory_with_mz_calibration(
548                data_path,
549                tof_intercept,
550                tof_slope,
551                im_lower,
552                im_upper,
553                scan_max_index,
554            ),
555            false => TimsDataLoader::new_lazy_with_mz_calibration(
556                data_path,
557                tof_intercept,
558                tof_slope,
559                im_lower,
560                im_upper,
561                scan_max_index,
562            ),
563        };
564
565        let dia_index = DiaIndex::new(&meta_data, &dia_ms_mis_info, &dia_ms_ms_windows);
566
567        TimsDatasetDIA {
568            loader,
569            global_meta_data,
570            meta_data,
571            dia_ms_ms_info: dia_ms_mis_info,
572            dia_ms_ms_windows,
573            dia_index,
574        }
575    }
576
577    pub fn program_for_group(&self, g: u32) -> Ms2GroupProgram {
578        self.dia_index.program_for_group(g)
579    }
580
581    /// Collect all program slices for a window group from DiaFrameMsMsWindows.
582    /// Isolation window: [isolation_mz - width/2, isolation_mz + width/2]
583    pub fn program_slices_for_group(&self, group: u32) -> Vec<ProgramSlice> {
584        self.dia_ms_ms_windows
585            .iter()
586            .filter(|w| w.window_group == group)
587            .map(|w| {
588                let half = 0.5 * w.isolation_width;
589                ProgramSlice {
590                    mz_lo: w.isolation_mz - half,
591                    mz_hi: w.isolation_mz + half,
592                    scan_lo: w.scan_num_begin,
593                    scan_hi: w.scan_num_end,
594                }
595            })
596            .collect()
597    }
598
599    /// Sample MS1 noise from `num_frames` random precursor frames in the
600    /// reference dataset, sum them into one combined TimsFrame, and stamp
601    /// the result's `ms_type` as `MsType::Precursor` so downstream
602    /// `simulated + noise` adds don't collapse the simulated frame's type
603    /// to `MsType::Unknown`.
604    ///
605    /// Empty-fallback handling
606    /// -----------------------
607    /// `loader.get_frame()` returns `TimsFrame::default()` (which is
608    /// `frame_id=0, ms_type=MsType::Unknown`) when the requested raw
609    /// frame has no scan data. If one of those Default-shaped frames is
610    /// the first element fed into the inner accumulator, every subsequent
611    /// `sampled_frame + frame` retains `frame_id=0, ms_type=Unknown` via
612    /// `TimsFrame::Add`'s correct "LHS frame_id wins; mismatched ms_type
613    /// → Unknown" semantics. That corrupted noise then leaks through the
614    /// outer simulator add. The fix here:
615    ///   1. Skip empty / Default-shaped candidates before they enter the
616    ///      accumulator (`scan.is_empty()` or `ms_type == Unknown`),
617    ///   2. Use a bounded retry pool so we don't hang on degenerate
618    ///      references (e.g. a near-empty blank gradient),
619    ///   3. Stamp the returned frame's `ms_type` explicitly — purely
620    ///      defensive, documents the post-condition.
621    pub fn sample_precursor_signal(
622        &self,
623        num_frames: usize,
624        max_intensity: f64,
625        take_probability: f64,
626    ) -> TimsFrame {
627        let target_type = MsType::Precursor;
628        let pool: Vec<&FrameMeta> = self
629            .meta_data
630            .iter()
631            .filter(|x| x.ms_ms_type == 0)
632            .collect();
633        let mut rng = rand::thread_rng();
634        let mut sampled_frames: Vec<TimsFrame> =
635            self.collect_typed_noise_samples(&pool, num_frames, max_intensity,
636                                             take_probability, &mut rng,
637                                             |meta| meta.id as u32, None);
638        Self::accumulate_or_typed_empty(&mut sampled_frames, target_type)
639    }
640
641    /// Sample MS2 noise restricted to a window_group from the reference
642    /// dataset. See `sample_precursor_signal` for the leak diagnosis and
643    /// the design notes; the only difference here is the candidate pool
644    /// (DIA-MS²-info filtered by window_group) and the type stamp
645    /// (`MsType::FragmentDia`).
646    pub fn sample_fragment_signal(
647        &self,
648        num_frames: usize,
649        window_group: u32,
650        max_intensity: f64,
651        take_probability: f64,
652    ) -> TimsFrame {
653        let target_type = MsType::FragmentDia;
654        let pool: Vec<u32> = self
655            .dia_ms_ms_info
656            .iter()
657            .filter(|x| x.window_group == window_group)
658            .map(|x| x.frame_id)
659            .collect();
660        let mut rng = rand::thread_rng();
661        let mut sampled_frames: Vec<TimsFrame> =
662            self.collect_typed_noise_samples(&pool, num_frames, max_intensity,
663                                             take_probability, &mut rng,
664                                             |&fid| fid, None);
665        Self::accumulate_or_typed_empty(&mut sampled_frames, target_type)
666    }
667
668    /// Internal: pull up to `num_frames` non-empty TimsFrames from `pool`
669    /// via the loader, with bounded retry to skip empty raws.
670    /// `key_fn` lifts a pool element to a frame_id.
671    fn collect_typed_noise_samples<T>(
672        &self,
673        pool: &[T],
674        num_frames: usize,
675        max_intensity: f64,
676        take_probability: f64,
677        rng: &mut impl rand::Rng,
678        key_fn: impl Fn(&T) -> u32,
679        conv: Option<&(dyn IndexConverter + Sync)>,
680    ) -> Vec<TimsFrame> {
681        let mut out: Vec<TimsFrame> = Vec::with_capacity(num_frames);
682        if pool.is_empty() || num_frames == 0 {
683            return out;
684        }
685        let max_attempts = (num_frames * 8).max(16);
686        let mut attempts = 0usize;
687        use rand::seq::SliceRandom;
688        while out.len() < num_frames && attempts < max_attempts {
689            attempts += 1;
690            let Some(candidate_meta) = pool.choose(rng) else { break };
691            let frame_id = key_fn(candidate_meta);
692            // `conv` is the SDK-free converter handed in by parallel callers (see
693            // `overlay_reference_noise`); the serial samplers read with the loader's own.
694            let raw = 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 candidate = raw
699                .filter_ranged(0.0, 2000.0, 0, 1000, 0.0, 5.0, 1.0,
700                               max_intensity, 0, i32::MAX)
701                .generate_random_sample_with_rng(take_probability, rng);
702            // Skip the loader's empty-frame Default-fallback so the
703            // returned noise never carries MsType::Unknown into the
704            // outer simulator add.
705            if candidate.scan.is_empty() || candidate.ms_type == MsType::Unknown {
706                continue;
707            }
708            out.push(candidate);
709        }
710        out
711    }
712
713    /// Internal: fold a Vec of typed noise samples into one via Add, and
714    /// return a *typed* empty TimsFrame if the input was empty so callers
715    /// don't get Default::default()'s Unknown back.
716    fn accumulate_or_typed_empty(
717        samples: &mut Vec<TimsFrame>,
718        target_type: MsType,
719    ) -> TimsFrame {
720        if samples.is_empty() {
721            return TimsFrame::new(
722                0, target_type, 0.0,
723                vec![], vec![], vec![], vec![], vec![],
724            );
725        }
726        let mut acc = samples.remove(0);
727        for f in samples.drain(..) {
728            acc = acc + f;
729        }
730        // Belt-and-braces: if the inner Add chain somehow collapsed to
731        // Unknown despite the input filter, restore the type the caller
732        // explicitly asked for.
733        acc.ms_type = target_type;
734        acc
735    }
736
737    /// All DIA window_group IDs present in the file (sorted unique).
738    /// Reference-noise overlay for a whole batch, in parallel and reproducible.
739    ///
740    /// For every simulated frame, draws `num_*_frames` random reference frames of the same kind
741    /// (precursor frames for MS1, frames of the same DIA window group for MS2), keeps a
742    /// `take_*` fraction of their peaks below `max_intensity_*`, accumulates them and adds the
743    /// result onto the simulated frame with `TimsFrame + TimsFrame` — exactly the per-frame
744    /// `sample_precursor_signal` / `sample_fragment_signal` + `frame + noise` sequence the
745    /// Python job used to run serially, but over a rayon pool and with a per-frame RNG stream
746    /// derived from `seed` and the frame id, so the result does not depend on scheduling.
747    ///
748    /// `window_groups[i]` is `Some(group)` for a fragment frame and `None` for a precursor frame.
749    pub fn overlay_reference_noise(
750        &self,
751        frames: Vec<TimsFrame>,
752        window_groups: Vec<Option<u32>>,
753        num_precursor_frames: usize,
754        num_fragment_frames: usize,
755        max_intensity_precursor: f64,
756        max_intensity_fragment: f64,
757        take_precursor: f64,
758        take_fragment: f64,
759        seed: u64,
760        num_threads: usize,
761    ) -> Vec<TimsFrame> {
762        assert_eq!(frames.len(), window_groups.len(), "frames and window_groups must align");
763        let precursor_pool: Vec<u32> = self
764            .meta_data
765            .iter()
766            .filter(|x| x.ms_ms_type == 0)
767            .map(|m| m.id as u32)
768            .collect();
769        let mut fragment_pools: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
770        for info in &self.dia_ms_ms_info {
771            fragment_pools.entry(info.window_group).or_default().push(info.frame_id);
772        }
773        // The Bruker SDK's `tims_index_to_mz` is not safe to call concurrently on one handle
774        // (same seed gave m/z that differed by ~1e-8 relative on ~8 % of peaks, run to run).
775        // Read the reference frames through the SDK-free formula converter instead; if the
776        // file carries no calibration tables, fall back to one thread so the result stays
777        // deterministic.
778        let sdk_free = self.loader.sdk_free_converter();
779        let threads = if self.loader.uses_bruker_sdk() && sdk_free.is_none() {
780            1
781        } else {
782            num_threads.max(1)
783        };
784        let conv: Option<&(dyn IndexConverter + Sync)> =
785            sdk_free.as_ref().map(|c| c as &(dyn IndexConverter + Sync));
786        let pool = rayon::ThreadPoolBuilder::new()
787            .num_threads(threads)
788            .build()
789            .unwrap();
790        pool.install(|| {
791            frames
792                .into_par_iter()
793                .zip(window_groups.into_par_iter())
794                .map(|(frame, wg)| {
795                    use rand::SeedableRng;
796                    let stream = (frame.frame_id as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
797                    let mut rng = rand::rngs::StdRng::seed_from_u64(seed ^ stream);
798                    let noise = match wg {
799                        Some(group) => {
800                            let ids: &[u32] = fragment_pools
801                                .get(&group)
802                                .map(|v| v.as_slice())
803                                .unwrap_or(&[]);
804                            let mut s = self.collect_typed_noise_samples(
805                                ids, num_fragment_frames, max_intensity_fragment,
806                                take_fragment, &mut rng, |&fid| fid, conv);
807                            Self::accumulate_or_typed_empty(&mut s, MsType::FragmentDia)
808                        }
809                        None => {
810                            let mut s = self.collect_typed_noise_samples(
811                                &precursor_pool, num_precursor_frames, max_intensity_precursor,
812                                take_precursor, &mut rng, |&fid| fid, conv);
813                            Self::accumulate_or_typed_empty(&mut s, MsType::Precursor)
814                        }
815                    };
816                    frame + noise
817                })
818                .collect()
819        })
820    }
821
822    pub fn dia_window_groups(&self) -> Vec<u32> {
823        let mut gs: Vec<u32> = self
824            .dia_ms_ms_info
825            .iter()
826            .map(|x| x.window_group)
827            .collect();
828        gs.sort_unstable();
829        gs.dedup();
830        gs
831    }
832
833    pub fn window_groups_for_precursor(&self, prec_mz: f32, im_apex: f32) -> Vec<u32> {
834        self.dia_index.groups_for_precursor(prec_mz, im_apex)
835    }
836
837    /// Map frame_id -> (time, ms_ms_type) for quick lookups.
838    fn frame_time_map(&self) -> FxHashMap<u32, (f32, i64)> {
839        let mut m = FxHashMap::default();
840        for fm in &self.meta_data {
841            m.insert(fm.id as u32, (fm.time as f32, fm.ms_ms_type));
842        }
843        m
844    }
845
846    /// RT-sorted **fragment** frames for a given DIA group.
847    pub fn fragment_frame_ids_and_times_for_group_core(&self, window_group: u32) -> (Vec<u32>, Vec<f32>) {
848        let time_map = self.frame_time_map();
849        let mut rows: Vec<(u32, f32)> = self
850            .dia_ms_ms_info
851            .iter()
852            .filter(|x| x.window_group == window_group)
853            .filter_map(|x| {
854                time_map.get(&(x.frame_id)).map(|(t, _ms2)| (x.frame_id, *t))
855            })
856            .collect();
857
858        // Just in case: keep only MS2 frames (ms_ms_type != 0)
859        rows.retain(|(fid, _)| time_map.get(fid).map(|(_, ty)| *ty != 0).unwrap_or(false));
860
861        rows.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());
862        let (ids, times): (Vec<_>, Vec<_>) = rows.into_iter().unzip();
863        (ids, times)
864    }
865
866    /// Merged, sorted **global scan** unions for this DIA group, from DiaFrameMsMsWindows.
867    /// Returns None if there are no window rows for this group.
868    pub fn scan_unions_for_window_group_core(&self, window_group: u32) -> Option<Vec<(usize, usize)>> {
869        let ranges: Vec<(usize, usize)> = self
870            .dia_ms_ms_windows
871            .iter()
872            .filter(|w| w.window_group == window_group)
873            .map(|w| {
874                let l = w.scan_num_begin as usize;
875                let r = w.scan_num_end as usize;
876                if l <= r { (l, r) } else { (r, l) }
877            })
878            .collect();
879        if ranges.is_empty() {
880            return None;
881        }
882        Some(merge_ranges(ranges))
883    }
884
885    /// m/z unions (min..max) for the group from DiaFrameMsMsWindows (wide clamp).
886    pub fn mz_bounds_for_window_group_core(&self, window_group: u32) -> Option<(f32, f32)> {
887        let mut lo = f32::INFINITY;
888        let mut hi = f32::NEG_INFINITY;
889        let mut hit = false;
890        for w in &self.dia_ms_ms_windows {
891            if w.window_group == window_group {
892                let c = w.isolation_mz as f32;
893                let half = 0.5f32 * (w.isolation_width as f32);
894                lo = lo.min(c - half);
895                hi = hi.max(c + half);
896                hit = true;
897            }
898        }
899        if hit && hi > lo && lo.is_finite() && hi.is_finite() {
900            Some((lo, hi))
901        } else {
902            None
903        }
904    }
905
906    /// RT-sorted FRAGMENT frames + times for a DIA group, converted into FrameBinView rows.
907    /// `tof_step` controls TOF granularity of CSR binning.
908    pub fn make_rt_frames_for_group(&self, window_group: u32, tof_step: i32) -> RtFrames {
909        assert!(tof_step > 0);
910
911        let (ids, times) = self.fragment_frame_ids_and_times_for_group_core(window_group);
912        assert!(
913            !ids.is_empty(),
914            "No MS2 frames for window_group {}",
915            window_group
916        );
917
918        let global_num_scans = self.meta_data
919            .iter()
920            .filter(|m| ids.binary_search(&(m.id as u32)).is_ok())
921            .map(|m| m.num_scans as usize)
922            .max()
923            .unwrap_or(0);
924
925        // TOF scale for this group
926        let scale = self.tof_scale_for_group(window_group, tof_step);
927
928        let frames: Vec<FrameBinView> = ids.par_iter()
929            .map(|&fid| build_frame_bin_view(self.get_frame(fid), &scale, global_num_scans))
930            .collect();
931
932        RtFrames {
933            frames,
934            frame_ids: ids,
935            rt_times: times,
936            scale: Arc::new(scale),
937        }
938    }
939
940    /// RT-sorted PRECURSOR frames (ms_ms_type == 0) into FrameBinView rows.
941    /// `tof_step` controls TOF granularity of CSR binning.
942    pub fn make_rt_frames_for_precursor(&self, tof_step: i32) -> RtFrames {
943        assert!(tof_step > 0);
944
945        let mut rows: Vec<(u32, f32, usize)> = self.meta_data
946            .iter()
947            .filter(|m| m.ms_ms_type == 0)
948            .map(|m| (m.id as u32, m.time as f32, m.num_scans as usize))
949            .collect();
950
951        assert!(!rows.is_empty(), "No precursor (MS1) frames found");
952        rows.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
953
954        let frame_ids: Vec<u32> = rows.iter().map(|r| r.0).collect();
955        let rt_times:  Vec<f32> = rows.iter().map(|r| r.1).collect();
956        let global_num_scans = rows.iter().map(|r| r.2).max().unwrap_or(1);
957
958        // Build TOF scale from precursor frames
959        let frames_for_scale: Vec<_> = frame_ids.iter().map(|&fid| self.get_frame(fid)).collect();
960        let scale = TofScale::build_from_tof(&frames_for_scale, tof_step)
961            .expect("make_rt_frames_for_precursor: failed to build TOF scale");
962
963        let frames: Vec<FrameBinView> = frame_ids.par_iter()
964            .map(|&fid| build_frame_bin_view(self.get_frame(fid), &scale, global_num_scans))
965            .collect();
966
967        RtFrames {
968            frames,
969            frame_ids,
970            rt_times,
971            scale: Arc::new(scale),
972        }
973    }
974
975    /// Build a TOF-based scale for a DIA group.
976    /// `tof_step = 1` → max TOF resolution; larger steps downsample.
977    pub fn tof_scale_for_group(&self, window_group: u32, tof_step: i32) -> TofScale {
978        assert!(tof_step > 0);
979
980        let (ids, _times) = self.fragment_frame_ids_and_times_for_group_core(window_group);
981        assert!(
982            !ids.is_empty(),
983            "tof_scale_for_group: no MS2 frames for window_group {}",
984            window_group
985        );
986
987        let frames: Vec<_> = ids.iter().map(|&fid| self.get_frame(fid)).collect();
988
989        TofScale::build_from_tof(&frames, tof_step)
990            .expect("tof_scale_for_group: failed to build TOF scale (empty or degenerate)")
991    }
992
993    /// Conservative: derive a TOF scale by scanning actual frame data.
994    pub fn tof_scale_from_frames_scan(
995        &self,
996        frame_ids: &[u32],
997        tof_step: i32,
998    ) -> Option<TofScale> {
999        assert!(tof_step > 0);
1000        let frames: Vec<_> = frame_ids.iter().map(|&fid| self.get_frame(fid)).collect();
1001        TofScale::build_from_tof(&frames, tof_step)
1002    }
1003
1004    /// Internal helper: go from a set of IM peaks to evaluated 1D clusters
1005    /// on a given RtFrames grid (precursor or DIA group).
1006    fn clusters_for_im_peaks_on_rt_frames(
1007        &self,
1008        rt: RtFrames,
1009        im_peaks: &[ImPeak1D],
1010        rt_params: RtExpandParams,
1011        build_opts: &BuildSpecOpts,
1012        eval_opts: &Eval1DOpts,
1013        require_rt_overlap: bool,
1014        num_threads: usize,
1015    ) -> Vec<ClusterResult1D> {
1016        // 1) Expand IM peaks along RT on this grid
1017        let rt_groups = expand_many_im_peaks_along_rt(
1018            im_peaks,
1019            &rt.frames,
1020            rt.ctx(),
1021            rt.scale.as_ref(),
1022            rt_params,
1023        );
1024
1025        // 2) Build 1D specs (RT × IM × TOF-window)
1026        let specs: Vec<ClusterSpec1D> = make_specs_from_im_and_rt_groups_threads(
1027            im_peaks,
1028            &rt_groups,
1029            &rt,
1030            build_opts,
1031            require_rt_overlap,
1032            num_threads,
1033        );
1034
1035        // 3) Evaluate all specs on this RtFrames grid
1036        self.evaluate_specs_1d_threads(&rt, &specs, eval_opts, num_threads)
1037    }
1038
1039    /// Evaluate a batch of 1D cluster specs on the given RtFrames.
1040    /// - Runs spec evaluation (marginals + moment fits) in parallel.
1041    /// - Optionally attaches raw points using the finalized **TOF-bin window**
1042    ///   and the spec’s IM/RT windows.
1043    /// - `num_threads == 0` => use rayon’s global pool.
1044    pub fn evaluate_specs_1d_threads(
1045        &self,
1046        rt_frames: &RtFrames,
1047        specs: &[ClusterSpec1D],
1048        opts: &Eval1DOpts,
1049        num_threads: usize,
1050    ) -> Vec<ClusterResult1D> {
1051        if specs.is_empty() {
1052            return Vec::new();
1053        }
1054
1055        let scale = &*rt_frames.scale;
1056
1057        let run = || {
1058            // Pre-build raw attach context once if needed
1059            let attach_ctx = if opts.attach.attach_points {
1060                let frame_ids_local = rt_frames.frame_ids.clone();
1061                let slice = self.get_slice(frame_ids_local.clone(), num_threads.max(1));
1062                let scan_slices = slice
1063                    .frames
1064                    .iter()
1065                    .map(|fr| build_scan_slices(fr))
1066                    .collect::<Vec<_>>();
1067                let rt_axis_sec = rt_frames.rt_times.clone();
1068
1069                Some(RawAttachContext {
1070                    slice,
1071                    scan_slices,
1072                    frame_ids_local,
1073                    rt_axis_sec,
1074                })
1075            } else {
1076                None
1077            };
1078
1079            specs
1080                .par_iter()
1081                .map(|spec| {
1082                    // 1) core 1D evaluation (RT/IM/TOF fits etc.)
1083                    let mut res = evaluate_spec_1d(rt_frames, spec, opts);
1084
1085                    // 2) decorate with m/z axis + mz_fit/mz_window
1086                    //
1087                    //    - uses the already-computed TOF window in `spec.tof_win`
1088                    //    - does *not* depend on raw_points being attached
1089                    //    - keeps the logic local and cheap
1090                    decorate_with_mz_for_cluster(self, rt_frames, &mut res);(self, rt_frames, spec, scale, &mut res);
1091
1092                    // 3) optional raw point attachment (using shared context)
1093                    if let Some(ref ctx) = attach_ctx {
1094                        if opts.attach.attach_points && res.raw_sum > 0.0 && res.tof_fit.area > 0.0 {
1095                            let (bin_lo, bin_hi) = bin_range_for_win(scale, spec.tof_win);
1096                            let (im_lo, im_hi) = (spec.im_lo, spec.im_hi);
1097                            let (rt_lo, rt_hi) = (spec.rt_lo, spec.rt_hi);
1098
1099                            let raw = attach_raw_points_for_spec_1d_in_ctx(
1100                                ctx,
1101                                scale,
1102                                bin_lo,
1103                                bin_hi,
1104                                im_lo,
1105                                im_hi,
1106                                rt_lo,
1107                                rt_hi,
1108                                opts.attach.max_points,
1109                            );
1110                            res.raw_points = Some(raw);
1111                        }
1112                    }
1113
1114                    res
1115                })
1116                .collect::<Vec<_>>()
1117        };
1118
1119        if num_threads == 0 {
1120            run()
1121        } else {
1122            ThreadPoolBuilder::new()
1123                .num_threads(num_threads)
1124                .build()
1125                .unwrap()
1126                .install(run)
1127        }
1128    }
1129
1130    pub fn clusters_for_group(
1131        &self,
1132        window_group: u32,
1133        tof_step: i32,
1134        im_peaks: &[ImPeak1D],
1135        rt_params: RtExpandParams,
1136        build_opts: &BuildSpecOpts,
1137        eval_opts: &Eval1DOpts,
1138        require_rt_overlap: bool,
1139        num_threads: usize,
1140    ) -> Vec<ClusterResult1D> {
1141        // Guard: all IM peaks must belong to this DIA group
1142        debug_assert!(
1143            im_peaks
1144                .iter()
1145                .all(|p| p.window_group == Some(window_group)),
1146            "clusters_for_group: some IM peaks have wrong or missing window_group"
1147        );
1148
1149        // Build RT+TOF grid for this DIA isolation window group.
1150        let rt = self.make_rt_frames_for_group(window_group, tof_step);
1151
1152        // Force MS level 2 for DIA fragments
1153        let build_opts_ms2 = build_opts.with_ms_level(2);
1154
1155        self.clusters_for_im_peaks_on_rt_frames(
1156            rt,
1157            im_peaks,
1158            rt_params,
1159            &build_opts_ms2,
1160            eval_opts,
1161            require_rt_overlap,
1162            num_threads,
1163        )
1164    }
1165
1166    pub fn clusters_for_precursor(
1167        &self,
1168        tof_step: i32,
1169        im_peaks: &[ImPeak1D],
1170        rt_params: RtExpandParams,
1171        build_opts: &BuildSpecOpts,
1172        eval_opts: &Eval1DOpts,
1173        require_rt_overlap: bool,
1174        num_threads: usize,
1175    ) -> Vec<ClusterResult1D> {
1176        // Guard: all IM peaks must be MS1 (no window_group)
1177        debug_assert!(
1178            im_peaks.iter().all(|p| p.window_group.is_none()),
1179            "clusters_for_precursor: IM peaks unexpectedly carry a window_group"
1180        );
1181
1182        // RT+TOF grid for precursor (MS1) frames
1183        let rt = self.make_rt_frames_for_precursor(tof_step);
1184
1185        // Force MS level 1 for precursor
1186        let build_opts_ms1 = build_opts.with_ms_level(1);
1187
1188        self.clusters_for_im_peaks_on_rt_frames(
1189            rt,
1190            im_peaks,
1191            rt_params,
1192            &build_opts_ms1,
1193            eval_opts,
1194            require_rt_overlap,
1195            num_threads,
1196        )
1197    }
1198
1199    /// End-to-end DIA → pseudo-DDA builder (geometric scoring).
1200    ///
1201    /// Thin wrapper around `build_pseudo_spectra_end_to_end`.
1202    pub fn build_pseudo_spectra_from_clusters_geom(
1203        &self,
1204        ms1: &[ClusterResult1D],
1205        ms2: &[ClusterResult1D],
1206        features: Option<&[SimpleFeature]>,
1207        cand_opts: &CandidateOpts,
1208        score_opts: &ScoreOpts,
1209        pseudo_opts: &PseudoSpecOpts,
1210    ) -> PseudoBuildResult {
1211        build_pseudo_spectra_end_to_end(
1212            self,
1213            ms1,
1214            ms2,
1215            features,
1216            cand_opts,
1217            score_opts,
1218            pseudo_opts,
1219        )
1220    }
1221
1222    /// End-to-end DIA → pseudo-DDA builder (XIC-based scoring).
1223    ///
1224    /// Thin wrapper around `build_pseudo_spectra_end_to_end_xic`.
1225    pub fn build_pseudo_spectra_from_clusters_xic(
1226        &self,
1227        ms1: &[ClusterResult1D],
1228        ms2: &[ClusterResult1D],
1229        features: Option<&[SimpleFeature]>,
1230        cand_opts: &CandidateOpts,
1231        xic_opts: &XicScoreOpts,
1232        pseudo_opts: &PseudoSpecOpts,
1233    ) -> PseudoBuildResult {
1234        build_pseudo_spectra_end_to_end_xic(
1235            self,
1236            ms1,
1237            ms2,
1238            features,
1239            cand_opts,
1240            xic_opts,
1241            pseudo_opts,
1242        )
1243    }
1244
1245    /// Naive "all pairs" builder stays as-is, still returning Vec<PseudoSpectrum>.
1246    pub fn build_pseudo_spectra_all_pairs_from_clusters(
1247        &self,
1248        ms1: &[ClusterResult1D],
1249        ms2: &[ClusterResult1D],
1250        features: Option<&[SimpleFeature]>,
1251        pseudo_opts: &PseudoSpecOpts,
1252    ) -> PseudoBuildResult {
1253        build_pseudo_spectra_all_pairs(self, ms1, ms2, features, pseudo_opts)
1254    }
1255
1256    // ==========================================================
1257    // Convenience methods using ClusterExtractionOpts (Phase 5)
1258    // ==========================================================
1259
1260    /// Extract clusters for a DIA window group using options struct.
1261    pub fn clusters_for_group_opts(
1262        &self,
1263        window_group: u32,
1264        im_peaks: &[ImPeak1D],
1265        opts: &ClusterExtractionOpts,
1266    ) -> Vec<ClusterResult1D> {
1267        self.clusters_for_group(
1268            window_group,
1269            opts.tof_step,
1270            im_peaks,
1271            opts.rt_params.clone(),
1272            &opts.build_opts,
1273            &opts.eval_opts,
1274            opts.require_rt_overlap,
1275            opts.num_threads,
1276        )
1277    }
1278
1279    /// Extract clusters for precursor (MS1) frames using options struct.
1280    pub fn clusters_for_precursor_opts(
1281        &self,
1282        im_peaks: &[ImPeak1D],
1283        opts: &ClusterExtractionOpts,
1284    ) -> Vec<ClusterResult1D> {
1285        self.clusters_for_precursor(
1286            opts.tof_step,
1287            im_peaks,
1288            opts.rt_params.clone(),
1289            &opts.build_opts,
1290            &opts.eval_opts,
1291            opts.require_rt_overlap,
1292            opts.num_threads,
1293        )
1294    }
1295
1296    /// Dense TOF×RT grid for all PRECURSOR (MS1) frames.
1297    /// `tof_step` controls TOF bin granularity (1 = full resolution).
1298    pub fn tof_rt_grid_precursor(&self, tof_step: i32) -> TofRtGrid {
1299        let rt = self.make_rt_frames_for_precursor(tof_step);
1300        build_tof_rt_grid_full(&rt, None)
1301    }
1302
1303    /// Dense TOF×RT grid for FRAGMENT frames in a DIA window group.
1304    /// `tof_step` controls TOF bin granularity (1 = full resolution).
1305    pub fn tof_rt_grid_for_group(&self, window_group: u32, tof_step: i32) -> TofRtGrid {
1306        let rt = self.make_rt_frames_for_group(window_group, tof_step);
1307        build_tof_rt_grid_full(&rt, Some(window_group))
1308    }
1309
1310    /// Debug helper: for a bunch of clusters that all live on the same
1311    /// DIA grid (either MS1 or one MS2 window_group), extract RawPoints
1312    /// per cluster.
1313    ///
1314    /// - `clusters`: precursor *or* fragment clusters.
1315    ///   All must have the same `ms_level`.
1316    /// - `window_group`: if you pass Some(g), we treat them as MS2 clusters
1317    ///   on that DIA group. If None and ms_level == 2, we try to infer g
1318    ///   from the first cluster’s `window_group`.
1319    /// - `tof_step`: must be the same as used during clustering.
1320    /// - `max_points`: optional thinning cap per cluster.
1321    /// - `num_threads`: used for `get_slice` in the raw extractor.
1322    pub fn debug_extract_raw_for_clusters(
1323        &self,
1324        clusters: &[ClusterResult1D],
1325        window_group: Option<u32>,
1326        tof_step: i32,
1327        max_points: Option<usize>,
1328        num_threads: usize,
1329    ) -> Vec<RawPoints> {
1330        if clusters.is_empty() {
1331            return Vec::new();
1332        }
1333
1334        // ---- 1. Basic sanity: consistent ms_level ---------------------------
1335        let ms_level = clusters[0].ms_level;
1336        debug_assert!(
1337            clusters.iter().all(|c| c.ms_level == ms_level),
1338            "debug_extract_raw_for_clusters: mixed ms_level in cluster list"
1339        );
1340
1341        // Optional filter by WG if caller wants to restrict.
1342        let clusters: Vec<ClusterResult1D> = clusters
1343            .iter()
1344            .filter(|c| match window_group {
1345                Some(g) => c.window_group == Some(g),
1346                None => true,
1347            })
1348            .cloned()
1349            .collect();
1350
1351        if clusters.is_empty() {
1352            return Vec::new();
1353        }
1354
1355        // ---- 2. Rebuild the RtFrames grid used for these clusters ----------
1356        //
1357        // For MS1: all clusters live on the precursor RtFrames grid.
1358        // For MS2: all clusters live on the RtFrames grid of a single window group.
1359        let rt_frames = if ms_level == 1 {
1360            // Precursor grid; we ignore window_group here.
1361            self.make_rt_frames_for_precursor(tof_step)
1362        } else {
1363            // Fragments: we need a valid DIA group.
1364            let g = match window_group {
1365                Some(g) => g,
1366                None => clusters[0]
1367                    .window_group
1368                    .expect("MS2 cluster without window_group; pass window_group explicitly"),
1369            };
1370            self.make_rt_frames_for_group(g, tof_step)
1371        };
1372
1373        // ---- 3. For each cluster, call the existing raw extractor ----------
1374        clusters
1375            .iter()
1376            .map(|c| {
1377                let (rt_lo, rt_hi) = c.rt_window;
1378                let (im_lo, im_hi) = c.im_window;
1379                let (tof_lo, tof_hi) = c.tof_window;
1380
1381                attach_raw_points_for_spec_1d_threads(
1382                    self,
1383                    &rt_frames,
1384                    tof_lo,
1385                    tof_hi,
1386                    im_lo,
1387                    im_hi,
1388                    rt_lo,
1389                    rt_hi,
1390                    max_points,
1391                    num_threads,
1392                )
1393            })
1394            .collect()
1395    }
1396}
1397
1398impl TimsData for TimsDatasetDIA {
1399    fn get_frame(&self, frame_id: u32) -> TimsFrame {
1400        self.loader.get_frame(frame_id)
1401    }
1402
1403    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
1404        self.loader.get_raw_frame(frame_id)
1405    }
1406
1407    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1408        self.loader.get_slice(frame_ids, num_threads)
1409    }
1410    fn get_acquisition_mode(&self) -> AcquisitionMode {
1411        self.loader.get_acquisition_mode().clone()
1412    }
1413
1414    fn get_frame_count(&self) -> i32 {
1415        self.loader.get_frame_count()
1416    }
1417
1418    fn get_data_path(&self) -> &str {
1419        &self.loader.get_data_path()
1420    }
1421}
1422
1423impl IndexConverter for TimsDatasetDIA {
1424    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1425        self.loader
1426            .get_index_converter()
1427            .tof_to_mz(frame_id, tof_values)
1428    }
1429
1430    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1431        self.loader
1432            .get_index_converter()
1433            .mz_to_tof(frame_id, mz_values)
1434    }
1435
1436    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1437        self.loader
1438            .get_index_converter()
1439            .scan_to_inverse_mobility(frame_id, scan_values)
1440    }
1441
1442    fn inverse_mobility_to_scan(
1443        &self,
1444        frame_id: u32,
1445        inverse_mobility_values: &Vec<f64>,
1446    ) -> Vec<u32> {
1447        self.loader
1448            .get_index_converter()
1449            .inverse_mobility_to_scan(frame_id, inverse_mobility_values)
1450    }
1451}
1452
1453pub fn attach_raw_points_for_spec_1d_threads(
1454    ds: &TimsDatasetDIA,
1455    rt_frames: &RtFrames,
1456    final_bin_lo: usize,
1457    final_bin_hi: usize,
1458    final_im_lo: usize,
1459    final_im_hi: usize,
1460    final_rt_lo: usize,
1461    final_rt_hi: usize,
1462    max_points: Option<usize>,
1463    num_threads: usize,
1464) -> RawPoints {
1465    let scale = &*rt_frames.scale;
1466
1467    // Defensive clamp of bin indices to the valid bin range.
1468    let n_bins = scale.num_bins();
1469    if n_bins == 0 {
1470        return RawPoints::default();
1471    }
1472
1473    let mut bin_lo = final_bin_lo.min(n_bins.saturating_sub(1));
1474    let mut bin_hi = final_bin_hi.min(n_bins.saturating_sub(1));
1475    if bin_lo > bin_hi {
1476        std::mem::swap(&mut bin_lo, &mut bin_hi);
1477    }
1478
1479    // Axis window in *TOF-axis units*:
1480    //   [edges[bin_lo], cushion_hi_edge(edges[bin_hi+1])]
1481    let mut axis_lo = scale.edges[bin_lo];
1482    let hi_edge_idx = (bin_hi + 1).min(scale.edges.len().saturating_sub(1));
1483    let mut axis_hi = cushion_hi_edge(scale, scale.edges[hi_edge_idx]);
1484
1485    // RT frames and slice
1486    let frame_ids_local = rt_frames.frame_ids[final_rt_lo..=final_rt_hi].to_vec();
1487    let slice = ds.get_slice(frame_ids_local.clone(), num_threads.max(1));
1488    let scan_slices: Vec<Vec<ScanSlice>> =
1489        slice.frames.iter().map(|fr| build_scan_slices(fr)).collect();
1490
1491    // 1) Count – now using **TOF** as the search axis.
1492    let mut total = 0usize;
1493    for (fi, fr) in slice.frames.iter().enumerate() {
1494        let tofs = &fr.tof;               // raw TOF indices
1495        for sl in &scan_slices[fi] {
1496            if sl.scan < final_im_lo || sl.scan > final_im_hi {
1497                continue;
1498            }
1499            let l = lower_bound_tof(tofs, sl.start, sl.end, axis_lo);
1500            let r = upper_bound_tof(tofs, sl.start, sl.end, axis_hi);
1501            total += r.saturating_sub(l);
1502        }
1503    }
1504
1505    // 2) Last-chance widen: if empty, expand by ±1 bin in TOF space.
1506    if total == 0 {
1507        let lo_idx = bin_lo.saturating_sub(1);
1508        let hi_edge_idx_wide =
1509            (bin_hi + 2).min(n_bins).min(scale.edges.len().saturating_sub(1));
1510
1511        let try_lo = scale.edges[lo_idx];
1512        let try_hi = cushion_hi_edge(scale, scale.edges[hi_edge_idx_wide]);
1513
1514        let mut total2 = 0usize;
1515        for (fi, fr) in slice.frames.iter().enumerate() {
1516            let tofs = &fr.tof;
1517            for sl in &scan_slices[fi] {
1518                if sl.scan < final_im_lo || sl.scan > final_im_hi {
1519                    continue;
1520                }
1521                let l = lower_bound_tof(tofs, sl.start, sl.end, try_lo);
1522                let r = upper_bound_tof(tofs, sl.start, sl.end, try_hi);
1523                total2 += r.saturating_sub(l);
1524            }
1525        }
1526
1527        if total2 > 0 {
1528            total = total2;
1529            axis_lo = try_lo;
1530            axis_hi = try_hi;
1531        }
1532    }
1533
1534    // Still empty → bail with empty container
1535    if total == 0 {
1536        return RawPoints::default();
1537    }
1538
1539    let stride = max_points.map(|cap| thin_stride(total, cap)).unwrap_or(1);
1540    let reserve = total / stride + 8;
1541
1542    let mut pts = RawPoints {
1543        mz: Vec::with_capacity(reserve),
1544        rt: Vec::with_capacity(reserve),
1545        im: Vec::with_capacity(reserve),
1546        scan: Vec::with_capacity(reserve),
1547        intensity: Vec::with_capacity(reserve),
1548        tof: Vec::with_capacity(reserve),
1549        frame: Vec::with_capacity(reserve),
1550    };
1551
1552    let rt_axis_sec = rt_frames.rt_times[final_rt_lo..=final_rt_hi].to_vec();
1553
1554    // 3) Extraction with thinning – again use TOF range for selection
1555    let mut seen = 0usize;
1556    for (fi, fr) in slice.frames.iter().enumerate() {
1557        let mz   = &fr.ims_frame.mz;
1558        let it   = &fr.ims_frame.intensity;
1559        let ims  = &fr.ims_frame.mobility;
1560        let tofs = &fr.tof;
1561
1562        let len_all = mz.len().min(it.len()).min(ims.len()).min(tofs.len());
1563        let rt_val = rt_axis_sec[fi];
1564        let frame_id = frame_ids_local[fi];
1565
1566        for sl in &scan_slices[fi] {
1567            let s_abs = sl.scan;
1568            if s_abs < final_im_lo || s_abs > final_im_hi {
1569                continue;
1570            }
1571
1572            let mut l = lower_bound_tof(tofs, sl.start, sl.end, axis_lo);
1573            let mut r = upper_bound_tof(tofs, sl.start, sl.end, axis_hi);
1574            if r > len_all {
1575                r = len_all;
1576            }
1577            if l >= r {
1578                continue;
1579            }
1580
1581            while l < r {
1582                if stride == 1 || (seen % stride == 0) {
1583                    // Note: we still *store* m/z, but the selection is done in TOF.
1584                    pts.mz.push(mz[l] as f32);
1585                    pts.rt.push(rt_val);
1586                    pts.im.push(ims[l] as f32);
1587                    pts.scan.push(s_abs as u32);
1588                    pts.intensity.push(it[l] as f32);
1589                    pts.frame.push(frame_id);
1590                    pts.tof.push(tofs[l]);
1591                }
1592                seen += 1;
1593                l += 1;
1594            }
1595        }
1596    }
1597
1598    pts
1599}
1600
1601/// Binary search in TOF array [start, end) for lower bound.
1602#[inline]
1603fn lower_bound_tof(tofs: &[i32], start: usize, end: usize, x: f32) -> usize {
1604    let mut lo = start;
1605    let mut hi = end;
1606    let xf = x as f64;
1607    while lo < hi {
1608        let mid = (lo + hi) >> 1;
1609        if (tofs[mid] as f64) < xf {
1610            lo = mid + 1;
1611        } else {
1612            hi = mid;
1613        }
1614    }
1615    lo
1616}
1617
1618/// Binary search in TOF array [start, end) for upper bound.
1619#[inline]
1620fn upper_bound_tof(tofs: &[i32], start: usize, end: usize, x: f32) -> usize {
1621    let mut lo = start;
1622    let mut hi = end;
1623    let xf = x as f64;
1624    while lo < hi {
1625        let mid = (lo + hi) >> 1;
1626        if (tofs[mid] as f64) <= xf {
1627            lo = mid + 1;
1628        } else {
1629            hi = mid;
1630        }
1631    }
1632    lo
1633}
1634
1635// ----------------------------------------------------------------------
1636// Helpers for axis-based extraction (no ppm, no mz_min/mz_max needed)
1637// ----------------------------------------------------------------------
1638
1639/// Slightly "cushion" the high edge used for upper_bound search.
1640///
1641/// Semantics:
1642/// - `hi_edge` is typically `scale.edges[bin_hi + 1]` (the upper edge of the last bin).
1643/// - We nudge it outward by a tiny fraction of a *typical* bin width so values
1644///   that sit exactly on the edge aren't accidentally excluded.
1645/// - This is axis-generic: it works whether the axis is TOF or m/z-like.
1646#[inline]
1647fn cushion_hi_edge(scale: &TofScale, hi_edge: f32) -> f32 {
1648    let edges = &scale.edges;
1649    if edges.len() >= 2 {
1650        // Use a small fraction of the first bin width as epsilon.
1651        let bw = (edges[1] - edges[0]).abs().max(1e-6);
1652        hi_edge + 0.01 * bw
1653    } else {
1654        hi_edge
1655    }
1656}
1657
1658#[inline]
1659fn thin_stride(total: usize, cap: usize) -> usize {
1660    if cap == 0 || total <= cap {
1661        1
1662    } else {
1663        (total + cap - 1) / cap
1664    }
1665}
1666
1667
1668#[cfg(test)]
1669mod tests {
1670    use super::*;
1671    use mscore::data::spectrum::MsType;
1672    use mscore::timstof::frame::TimsFrame;
1673
1674    fn make_frame(frame_id: i32, ms_type: MsType) -> TimsFrame {
1675        // minimal non-empty frame: one scan at scan=10, mobility=0.8, tof=500,
1676        // mz=500.0, intensity=10.0 — enough for Add to have something to merge.
1677        TimsFrame::new(
1678            frame_id,
1679            ms_type,
1680            0.0,
1681            vec![10],
1682            vec![0.8],
1683            vec![500],
1684            vec![500.0],
1685            vec![10.0],
1686        )
1687    }
1688
1689    #[test]
1690    fn accumulate_or_typed_empty_returns_typed_empty_when_pool_is_empty() {
1691        // The whole point of the fix: an empty sample pool must not
1692        // return TimsFrame::default() (frame_id=0, ms_type=Unknown). It
1693        // must return a typed empty so the caller's `frame + noise`
1694        // doesn't collapse the simulated frame's ms_type to Unknown.
1695        let mut samples: Vec<TimsFrame> = vec![];
1696        let noise = TimsDatasetDIA::accumulate_or_typed_empty(
1697            &mut samples, MsType::Precursor,
1698        );
1699        assert_eq!(noise.ms_type, MsType::Precursor,
1700                   "typed-empty must carry the requested MsType, not Unknown");
1701        assert!(noise.scan.is_empty());
1702        assert!(noise.ims_frame.mz.is_empty());
1703    }
1704
1705    #[test]
1706    fn accumulate_or_typed_empty_stamps_type_even_after_inner_unknown() {
1707        // Belt-and-braces: even if some path managed to insert an
1708        // MsType::Unknown sample into the accumulator (which the outer
1709        // filter should prevent), the final stamp must restore the
1710        // requested type so downstream `simulated + noise` doesn't see
1711        // a mismatched operand.
1712        let mut samples = vec![
1713            make_frame(1, MsType::Unknown),
1714            make_frame(2, MsType::FragmentDia),
1715        ];
1716        let noise = TimsDatasetDIA::accumulate_or_typed_empty(
1717            &mut samples, MsType::FragmentDia,
1718        );
1719        assert_eq!(noise.ms_type, MsType::FragmentDia);
1720    }
1721
1722    #[test]
1723    fn accumulate_preserves_type_on_clean_input() {
1724        let mut samples = vec![
1725            make_frame(1, MsType::FragmentDia),
1726            make_frame(2, MsType::FragmentDia),
1727            make_frame(3, MsType::FragmentDia),
1728        ];
1729        let noise = TimsDatasetDIA::accumulate_or_typed_empty(
1730            &mut samples, MsType::FragmentDia,
1731        );
1732        assert_eq!(noise.ms_type, MsType::FragmentDia);
1733        // Inputs were merged; the accumulator should hold a non-empty frame.
1734        assert!(!noise.scan.is_empty());
1735    }
1736
1737    #[test]
1738    fn typed_empty_frame_uses_correct_default_frame_id() {
1739        // Empty-pool fallback should use frame_id=0 — same as
1740        // TimsFrame::default() — but with the requested ms_type stamped.
1741        // This pairs with the writer-side Frames.Id uniqueness check
1742        // (packages/imspy-simulation/src/imspy_simulation/tdf.py):
1743        // empty noise frames must not silently pollute the Frames table.
1744        let mut samples: Vec<TimsFrame> = vec![];
1745        let noise = TimsDatasetDIA::accumulate_or_typed_empty(
1746            &mut samples, MsType::Precursor,
1747        );
1748        assert_eq!(noise.frame_id, 0);
1749        assert_eq!(noise.ims_frame.retention_time, 0.0);
1750    }
1751}