Skip to main content

rustdf/data/
handle.rs

1use crate::data::meta::{read_global_meta_sql, read_meta_data_sql, FrameMeta, GlobalMetaData};
2use crate::data::raw::BrukerTimsDataLibrary;
3use crate::data::utility::{
4    flatten_scan_values, parse_decompressed_bruker_binary_data, zstd_decompress,
5};
6use byteorder::{LittleEndian, ReadBytesExt};
7use mscore::data::spectrum::MsType;
8use mscore::timstof::frame::{ImsFrame, RawTimsFrame, TimsFrame};
9use mscore::timstof::slice::TimsSlice;
10use std::fs::File;
11use std::io::{Cursor, Read, Seek, SeekFrom};
12use std::path::PathBuf;
13
14use crate::data::acquisition::AcquisitionMode;
15use rayon::prelude::*;
16use rayon::ThreadPoolBuilder;
17
18use std::error::Error;
19
20/// Derive m/z calibration coefficients by using the Bruker SDK.
21///
22/// This function temporarily loads the SDK to convert a range of TOF indices to m/z,
23/// then fits a linear regression to derive accurate calibration coefficients.
24///
25/// Returns `Some((intercept, slope))` for the formula: `sqrt(mz) = intercept + slope * tof`
26/// Returns `None` if SDK is not available (e.g., on macOS).
27fn derive_mz_calibration(
28    bruker_lib_path: &str,
29    data_path: &str,
30    tof_max_index: u32,
31) -> Option<(f64, f64)> {
32    // Check if SDK path is valid before trying to load
33    // Common invalid values: "NO_SDK", empty string, or non-existent paths
34    if bruker_lib_path.is_empty()
35        || bruker_lib_path == "NO_SDK"
36        || bruker_lib_path == "CALIBRATED"
37        || !std::path::Path::new(bruker_lib_path).exists()
38    {
39        return None;
40    }
41
42    // Try to create a BrukerLib converter. Use the FALLIBLE `try_new`, not `catch_unwind` around the
43    // panicking `new`: the workspace builds release with `panic = "abort"`, so catch_unwind can't
44    // recover — a present-but-unloadable SDK (wrong arch / mismatched version) would abort instead of
45    // returning None. try_new returns Err on load failure with no panic.
46    let sdk_converter = match BrukerLibTimsDataConverter::try_new(bruker_lib_path, data_path) {
47        Ok(converter) => converter,
48        Err(_) => return None,
49    };
50
51    // Generate a range of TOF indices across the spectrum
52    let n_points = 1000;
53    let step = tof_max_index / n_points;
54    let tof_indices: Vec<u32> = (0..n_points).map(|i| i * step + step / 2).collect();
55
56    // Convert to m/z using SDK
57    let mz_values = sdk_converter.tof_to_mz(1, &tof_indices);
58
59    // Filter out any invalid values (zero or negative)
60    let valid_pairs: Vec<(f64, f64)> = tof_indices
61        .iter()
62        .zip(mz_values.iter())
63        .filter(|(_, mz)| **mz > 0.0)
64        .map(|(tof, mz)| (*tof as f64, mz.sqrt()))
65        .collect();
66
67    if valid_pairs.len() < 10 {
68        return None;
69    }
70
71    // Linear regression: sqrt(mz) = intercept + slope * tof
72    // Using simple least squares: slope = Cov(x,y) / Var(x), intercept = mean_y - slope * mean_x
73    let n = valid_pairs.len() as f64;
74    let sum_x: f64 = valid_pairs.iter().map(|(x, _)| x).sum();
75    let sum_y: f64 = valid_pairs.iter().map(|(_, y)| y).sum();
76    let sum_xy: f64 = valid_pairs.iter().map(|(x, y)| x * y).sum();
77    let sum_xx: f64 = valid_pairs.iter().map(|(x, _)| x * x).sum();
78
79    let mean_x = sum_x / n;
80    let mean_y = sum_y / n;
81
82    let slope = (sum_xy - n * mean_x * mean_y) / (sum_xx - n * mean_x * mean_x);
83    let intercept = mean_y - slope * mean_x;
84
85    Some((intercept, slope))
86}
87
88/// Build a `LookupIndexConverter`, preferring an SDK-derived m/z calibration.
89///
90/// The `LookupIndexConverter` always carries the pre-computed scan→1/K0 lookup
91/// for ion mobility. For m/z it normally uses a 2-point boundary model
92/// (`LookupIndexConverter::new`), which can have a large m/z error on some
93/// datasets. When a valid `bruker_lib_path` is supplied, m/z is instead
94/// calibrated with the same regression fit used by the `Calibrated` converter
95/// (`derive_mz_calibration`). Falls back to the boundary model when the SDK is
96/// unavailable (e.g. macOS, or `bruker_lib_path` is empty / "NO_SDK").
97fn build_lookup_converter(
98    bruker_lib_path: &str,
99    data_path: &str,
100    tof_max_index: u32,
101    mz_lower: f64,
102    mz_upper: f64,
103    im_lookup: Vec<f64>,
104) -> LookupIndexConverter {
105    match derive_mz_calibration(bruker_lib_path, data_path, tof_max_index) {
106        Some((intercept, slope)) => {
107            LookupIndexConverter::with_mz_fit(intercept, slope, im_lookup)
108        }
109        None => {
110            if !bruker_lib_path.is_empty() && bruker_lib_path != "NO_SDK" {
111                eprintln!(
112                    "Warning: Could not derive m/z calibration from SDK at '{}'. \
113                    Falling back to the 2-point boundary model, which may have a \
114                    large m/z error on some datasets.",
115                    bruker_lib_path
116                );
117            }
118            LookupIndexConverter::new(mz_lower, mz_upper, tof_max_index, im_lookup)
119        }
120    }
121}
122
123fn lzf_decompress(data: &[u8], max_output_size: usize) -> Result<Vec<u8>, Box<dyn Error>> {
124    let decompressed_data = lzf::decompress(data, max_output_size)
125        .map_err(|e| format!("LZF decompression failed: {}", e))?;
126    Ok(decompressed_data)
127}
128
129fn parse_decompressed_bruker_binary_type1(
130    decompressed_bytes: &[u8],
131    scan_indices: &mut [i64],
132    tof_indices: &mut [u32],
133    intensities: &mut [u16],
134    scan_start: usize,
135    scan_index: usize,
136) -> usize {
137    // Interpret decompressed_bytes as a slice of i32
138    let int_count = decompressed_bytes.len() / 4;
139    let buffer =
140        unsafe { std::slice::from_raw_parts(decompressed_bytes.as_ptr() as *const i32, int_count) };
141
142    let mut tof_index = 0i32;
143    let mut previous_was_intensity = true;
144    let mut current_index = scan_start;
145
146    for &value in buffer {
147        if value >= 0 {
148            // positive value => intensity
149            if previous_was_intensity {
150                tof_index += 1;
151            }
152            tof_indices[current_index] = tof_index as u32;
153            intensities[current_index] = value as u16;
154            previous_was_intensity = true;
155            current_index += 1;
156        } else {
157            // negative value => indicates a jump in tof_index
158            tof_index -= value; // value is negative, so this adds |value| to tof_index
159            previous_was_intensity = false;
160        }
161    }
162
163    let scan_size = current_index - scan_start;
164    scan_indices[scan_index] = scan_size as i64;
165    scan_size
166}
167
168pub struct TimsRawDataLayout {
169    pub raw_data_path: String,
170    pub global_meta_data: GlobalMetaData,
171    pub frame_meta_data: Vec<FrameMeta>,
172    pub max_scan_count: i64,
173    pub frame_id_ptr: Vec<i64>,
174    pub tims_offset_values: Vec<i64>,
175    pub acquisition_mode: AcquisitionMode,
176}
177
178impl TimsRawDataLayout {
179    pub fn new(data_path: &str) -> Self {
180        // get the global and frame meta data
181        let global_meta_data = read_global_meta_sql(data_path).unwrap();
182        let frame_meta_data = read_meta_data_sql(data_path).unwrap();
183
184        // get the max scan count
185        let max_scan_count = frame_meta_data.iter().map(|x| x.num_scans).max().unwrap();
186
187        let mut frame_id_ptr: Vec<i64> = Vec::new();
188        frame_id_ptr.resize(frame_meta_data.len() + 1, 0);
189
190        // get the frame id_ptr values
191        for (i, row) in frame_meta_data.iter().enumerate() {
192            frame_id_ptr[i + 1] = row.num_peaks + frame_id_ptr[i];
193        }
194
195        // get the tims offset values
196        let tims_offset_values = frame_meta_data
197            .iter()
198            .map(|x| x.tims_id)
199            .collect::<Vec<i64>>();
200
201        // get the acquisition mode
202        let acquisition_mode = match frame_meta_data[0].scan_mode {
203            8 => AcquisitionMode::DDA,
204            9 => AcquisitionMode::DIA,
205            _ => AcquisitionMode::Unknown,
206        };
207
208        TimsRawDataLayout {
209            raw_data_path: data_path.to_string(),
210            global_meta_data,
211            frame_meta_data,
212            max_scan_count,
213            frame_id_ptr,
214            tims_offset_values,
215            acquisition_mode,
216        }
217    }
218}
219
220pub trait TimsData {
221    fn get_frame(&self, frame_id: u32) -> TimsFrame;
222    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame;
223    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice;
224    fn get_acquisition_mode(&self) -> AcquisitionMode;
225    fn get_frame_count(&self) -> i32;
226    fn get_data_path(&self) -> &str;
227}
228
229pub trait IndexConverter {
230    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64>;
231    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32>;
232    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64>;
233    fn inverse_mobility_to_scan(
234        &self,
235        frame_id: u32,
236        inverse_mobility_values: &Vec<f64>,
237    ) -> Vec<u32>;
238}
239
240pub struct BrukerLibTimsDataConverter {
241    pub bruker_lib: BrukerTimsDataLibrary,
242}
243
244impl BrukerLibTimsDataConverter {
245    pub fn new(bruker_lib_path: &str, data_path: &str) -> Self {
246        Self::try_new(bruker_lib_path, data_path).unwrap()
247    }
248    /// Fallible constructor — `Err` if the Bruker SDK shared library can't be loaded (missing,
249    /// wrong architecture, mismatched version). Lets callers degrade to the simple/derived
250    /// calibration instead of panicking.
251    pub fn try_new(
252        bruker_lib_path: &str,
253        data_path: &str,
254    ) -> Result<Self, Box<dyn std::error::Error>> {
255        let bruker_lib = BrukerTimsDataLibrary::new(bruker_lib_path, data_path)?;
256        Ok(BrukerLibTimsDataConverter { bruker_lib })
257    }
258}
259impl IndexConverter for BrukerLibTimsDataConverter {
260    /// translate tof to mz values calling the bruker library
261    ///
262    /// # Arguments
263    ///
264    /// * `frame_id` - A u32 that holds the frame id
265    /// * `tof` - A vector of u32 that holds the tof values
266    ///
267    /// # Returns
268    ///
269    /// * `mz_values` - A vector of f64 that holds the mz values
270    ///
271    fn tof_to_mz(&self, frame_id: u32, tof: &Vec<u32>) -> Vec<f64> {
272        let mut dbl_tofs: Vec<f64> = Vec::new();
273        dbl_tofs.resize(tof.len(), 0.0);
274
275        for (i, &val) in tof.iter().enumerate() {
276            dbl_tofs[i] = val as f64;
277        }
278
279        let mut mz_values: Vec<f64> = Vec::new();
280        mz_values.resize(tof.len(), 0.0);
281
282        self.bruker_lib
283            .tims_index_to_mz(frame_id, &dbl_tofs, &mut mz_values)
284            .expect("Bruker binary call failed at: tims_index_to_mz;");
285
286        mz_values
287    }
288
289    fn mz_to_tof(&self, frame_id: u32, mz: &Vec<f64>) -> Vec<u32> {
290        let mut dbl_mz: Vec<f64> = Vec::new();
291        dbl_mz.resize(mz.len(), 0.0);
292
293        for (i, &val) in mz.iter().enumerate() {
294            dbl_mz[i] = val;
295        }
296
297        let mut tof_values: Vec<f64> = Vec::new();
298        tof_values.resize(mz.len(), 0.0);
299
300        self.bruker_lib
301            .tims_mz_to_index(frame_id, &dbl_mz, &mut tof_values)
302            .expect("Bruker binary call failed at: tims_mz_to_index;");
303
304        tof_values.iter().map(|&x| x.round() as u32).collect()
305    }
306
307    /// translate scan to inverse mobility values calling the bruker library
308    ///
309    /// # Arguments
310    ///
311    /// * `frame_id` - A u32 that holds the frame id
312    /// * `scan` - A vector of i32 that holds the scan values
313    ///
314    /// # Returns
315    ///
316    /// * `inv_mob` - A vector of f64 that holds the inverse mobility values
317    ///
318    fn scan_to_inverse_mobility(&self, frame_id: u32, scan: &Vec<u32>) -> Vec<f64> {
319        let mut dbl_scans: Vec<f64> = Vec::new();
320        dbl_scans.resize(scan.len(), 0.0);
321
322        for (i, &val) in scan.iter().enumerate() {
323            dbl_scans[i] = val as f64;
324        }
325
326        let mut inv_mob: Vec<f64> = Vec::new();
327        inv_mob.resize(scan.len(), 0.0);
328
329        self.bruker_lib
330            .tims_scan_to_inv_mob(frame_id, &dbl_scans, &mut inv_mob)
331            .expect("Bruker binary call failed at: tims_scannum_to_oneoverk0;");
332
333        inv_mob
334    }
335
336    /// translate inverse mobility to scan values calling the bruker library
337    ///
338    /// # Arguments
339    ///
340    /// * `frame_id` - A u32 that holds the frame id
341    /// * `inv_mob` - A vector of f64 that holds the inverse mobility values
342    ///
343    /// # Returns
344    ///
345    /// * `scan_values` - A vector of i32 that holds the scan values
346    ///
347    fn inverse_mobility_to_scan(&self, frame_id: u32, inv_mob: &Vec<f64>) -> Vec<u32> {
348        let mut dbl_inv_mob: Vec<f64> = Vec::new();
349        dbl_inv_mob.resize(inv_mob.len(), 0.0);
350
351        for (i, &val) in inv_mob.iter().enumerate() {
352            dbl_inv_mob[i] = val;
353        }
354
355        let mut scan_values: Vec<f64> = Vec::new();
356        scan_values.resize(inv_mob.len(), 0.0);
357
358        self.bruker_lib
359            .inv_mob_to_tims_scan(frame_id, &dbl_inv_mob, &mut scan_values)
360            .expect("Bruker binary call failed at: tims_oneoverk0_to_scannum;");
361
362        scan_values.iter().map(|&x| x.round() as u32).collect()
363    }
364}
365
366/// SDK-free converter using the exact Bruker calibration formulas.
367///
368/// Reads the `MzCalibration`, `TimsCalibration` and `Frames` tables from
369/// analysis.tdf and evaluates the published calibration curves directly (see
370/// [`crate::data::calibration`]). Verified against the Bruker SDK:
371///   * scan <-> 1/K0 : machine-precision exact (ModelType 2).
372///   * TOF  <-> m/z  : bit-exact for MzCalibration ModelType 1; ModelType 2 uses
373///     the same C0/C1/C2 quadratic-in-sqrt(m) curve and reproduces the SDK to
374///     ~1 ppm (the proprietary ModelType-2 C8..C14 fine correction is not
375///     modelled).
376///
377/// The calibration is resolved **per frame**: every frame's `Frames.T1`/`T2` and
378/// its `MzCalibration`/`TimsCalibration` foreign keys are read, so a run that
379/// carries more than one calibration row is handled, and the temperature
380/// compensation is evaluated for the frame the caller asks about. That matters
381/// because `dC1` is ~20 ppm/degC: pinning one frame's temperature for a whole run
382/// costs ~20 ppm of mass accuracy per degC the digitizer drifts.
383/// [`Self::temperature_spread`] reports how far the temperatures actually moved,
384/// so callers can tell a quiet run from a drifting one.
385///
386/// Frame ids that are not in the file, and frames whose calibration rows use an
387/// unsupported ModelType, fall back to the calibrators built from
388/// `fallback_frame_id`.
389pub struct BrukerFormulaConverter {
390    /// Calibrator built from `fallback_frame_id`; also used for unknown frames.
391    pub mz: crate::data::calibration::MzCalibrator,
392    /// Mobility calibrator for `fallback_frame_id`; also used for unknown frames.
393    pub im: crate::data::calibration::MobilityCalibrator,
394    /// `MzCalibration.ModelType` of the fallback frame's row.
395    pub mz_model_type: i64,
396    /// Supported `MzCalibration` rows, by `Id`.
397    mz_rows: std::collections::HashMap<i64, crate::data::meta::MzCalibration>,
398    /// Ready-built mobility calibrators, by `TimsCalibration.Id`.
399    im_rows: std::collections::HashMap<i64, crate::data::calibration::MobilityCalibrator>,
400    /// Per-frame calibration linkage and digitizer temperatures.
401    frames: std::collections::HashMap<u32, FrameCalibrationRef>,
402    /// (T1, T2) spread (max - min) over all frames of the run, in degC.
403    temp_spread: (f64, f64),
404}
405
406/// One frame's link into the calibration tables, plus its digitizer temperatures.
407struct FrameCalibrationRef {
408    mz_id: i64,
409    tims_id: i64,
410    t1: f64,
411    t2: f64,
412}
413
414impl BrukerFormulaConverter {
415    /// Build the converter from a `.d` folder.
416    ///
417    /// `fallback_frame_id` (conventionally 1) selects the calibration used for
418    /// frames that are missing from the `Frames` table or whose calibration rows
419    /// this converter cannot evaluate; every frame that *is* in the file uses its
420    /// own rows and temperatures.
421    pub fn from_d_folder(
422        data_path: &str,
423        fallback_frame_id: u32,
424    ) -> Result<Self, Box<dyn std::error::Error>> {
425        use crate::data::calibration::{MobilityCalibrator, MzCalibrator};
426        use crate::data::meta::{read_mz_calibration, read_tims_calibration};
427        use std::collections::HashMap;
428
429        let tdf = PathBuf::from(data_path).join("analysis.tdf");
430        let con = rusqlite::Connection::open(&tdf)?;
431
432        // Per-frame calibration linkage + digitizer temperatures for the whole run.
433        // T1/T2 are nullable in Bruker's schema; frames without them cannot be
434        // temperature compensated and are left to the fallback calibration.
435        let mut stmt =
436            con.prepare("SELECT Id, T1, T2, MzCalibration, TimsCalibration FROM Frames")?;
437        let rows = stmt.query_map([], |r| {
438            Ok((
439                r.get::<_, i64>(0)?,
440                r.get::<_, Option<f64>>(1)?,
441                r.get::<_, Option<f64>>(2)?,
442                r.get::<_, i64>(3)?,
443                r.get::<_, i64>(4)?,
444            ))
445        })?;
446
447        let mut frames: HashMap<u32, FrameCalibrationRef> = HashMap::new();
448        let (mut t1_min, mut t1_max) = (f64::INFINITY, f64::NEG_INFINITY);
449        let (mut t2_min, mut t2_max) = (f64::INFINITY, f64::NEG_INFINITY);
450        for row in rows {
451            let (id, t1, t2, mz_id, tims_id) = row?;
452            let (t1, t2) = match (t1, t2) {
453                (Some(t1), Some(t2)) => (t1, t2),
454                _ => continue,
455            };
456            t1_min = t1_min.min(t1);
457            t1_max = t1_max.max(t1);
458            t2_min = t2_min.min(t2);
459            t2_max = t2_max.max(t2);
460            frames.insert(
461                id as u32,
462                FrameCalibrationRef {
463                    mz_id,
464                    tims_id,
465                    t1,
466                    t2,
467                },
468            );
469        }
470        drop(stmt);
471
472        let fallback = frames
473            .get(&fallback_frame_id)
474            .ok_or("fallback frame has no calibration/temperature row in Frames")?;
475
476        let mz_all = read_mz_calibration(data_path)?;
477        let tims_all = read_tims_calibration(data_path)?;
478
479        let mzc = mz_all
480            .iter()
481            .find(|c| c.id == fallback.mz_id)
482            .ok_or("MzCalibration row not found")?;
483        let tc = tims_all
484            .iter()
485            .find(|c| c.id == fallback.tims_id)
486            .ok_or("TimsCalibration row not found")?;
487
488        // Reject calibration models this converter does not implement, rather
489        // than silently mis-computing (m/z model 2 is the default branch).
490        if mzc.model_type != 1 && mzc.model_type != 2 {
491            return Err(format!("unsupported MzCalibration ModelType {}", mzc.model_type).into());
492        }
493        if tc.model_type != 2 {
494            return Err(format!("unsupported TimsCalibration ModelType {}", tc.model_type).into());
495        }
496
497        let mz = MzCalibrator::from_calibration(mzc, fallback.t1, fallback.t2);
498        let im = MobilityCalibrator::from_calibration(tc);
499        let mz_model_type = mzc.model_type;
500
501        // Keep only the rows this converter can evaluate; frames pointing at any
502        // other row fall back to the calibration above.
503        let mz_rows: HashMap<i64, crate::data::meta::MzCalibration> = mz_all
504            .into_iter()
505            .filter(|c| c.model_type == 1 || c.model_type == 2)
506            .map(|c| (c.id, c))
507            .collect();
508        let im_rows: HashMap<i64, MobilityCalibrator> = tims_all
509            .iter()
510            .filter(|c| c.model_type == 2)
511            .map(|c| (c.id, MobilityCalibrator::from_calibration(c)))
512            .collect();
513
514        let temp_spread = if frames.is_empty() {
515            (0.0, 0.0)
516        } else {
517            (t1_max - t1_min, t2_max - t2_min)
518        };
519
520        Ok(Self {
521            mz,
522            im,
523            mz_model_type,
524            mz_rows,
525            im_rows,
526            frames,
527            temp_spread,
528        })
529    }
530
531    /// Spread (max - min, in degC) of `Frames.T1` and `Frames.T2` across the run.
532    ///
533    /// A near-zero spread means per-frame and fixed-frame calibration agree; a
534    /// large one means the digitizer temperature moved and per-frame
535    /// compensation is doing real work (~20 ppm of m/z per degC).
536    pub fn temperature_spread(&self) -> (f64, f64) {
537        self.temp_spread
538    }
539
540    /// m/z calibrator for `frame_id`, falling back to the default calibration.
541    fn mz_for(&self, frame_id: u32) -> crate::data::calibration::MzCalibrator {
542        self.frames
543            .get(&frame_id)
544            .and_then(|f| {
545                self.mz_rows.get(&f.mz_id).map(|row| {
546                    crate::data::calibration::MzCalibrator::from_calibration(row, f.t1, f.t2)
547                })
548            })
549            .unwrap_or_else(|| self.mz.clone())
550    }
551
552    /// Mobility calibrator for `frame_id`, falling back to the default one.
553    fn im_for(&self, frame_id: u32) -> &crate::data::calibration::MobilityCalibrator {
554        self.frames
555            .get(&frame_id)
556            .and_then(|f| self.im_rows.get(&f.tims_id))
557            .unwrap_or(&self.im)
558    }
559}
560
561impl IndexConverter for BrukerFormulaConverter {
562    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
563        let mz = self.mz_for(frame_id);
564        tof_values.iter().map(|&t| mz.tof_to_mz(t)).collect()
565    }
566    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
567        let mz = self.mz_for(frame_id);
568        mz_values.iter().map(|&m| mz.mz_to_tof(m)).collect()
569    }
570    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
571        let im = self.im_for(frame_id);
572        scan_values
573            .iter()
574            .map(|&s| im.scan_to_one_over_k0(s))
575            .collect()
576    }
577    fn inverse_mobility_to_scan(
578        &self,
579        frame_id: u32,
580        inverse_mobility_values: &Vec<f64>,
581    ) -> Vec<u32> {
582        let im = self.im_for(frame_id);
583        inverse_mobility_values
584            .iter()
585            .map(|&v| im.one_over_k0_to_scan(v))
586            .collect()
587    }
588}
589
590pub enum TimsIndexConverter {
591    Simple(SimpleIndexConverter),
592    Calibrated(CalibratedIndexConverter),
593    BrukerLib(BrukerLibTimsDataConverter),
594    Lookup(LookupIndexConverter),
595    BrukerFormula(BrukerFormulaConverter),
596}
597
598impl IndexConverter for TimsIndexConverter {
599    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
600        match self {
601            TimsIndexConverter::Simple(converter) => converter.tof_to_mz(frame_id, tof_values),
602            TimsIndexConverter::Calibrated(converter) => converter.tof_to_mz(frame_id, tof_values),
603            TimsIndexConverter::BrukerLib(converter) => converter.tof_to_mz(frame_id, tof_values),
604            TimsIndexConverter::Lookup(converter) => converter.tof_to_mz(frame_id, tof_values),
605            TimsIndexConverter::BrukerFormula(converter) => converter.tof_to_mz(frame_id, tof_values),
606        }
607    }
608
609    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
610        match self {
611            TimsIndexConverter::Simple(converter) => converter.mz_to_tof(frame_id, mz_values),
612            TimsIndexConverter::Calibrated(converter) => converter.mz_to_tof(frame_id, mz_values),
613            TimsIndexConverter::BrukerLib(converter) => converter.mz_to_tof(frame_id, mz_values),
614            TimsIndexConverter::Lookup(converter) => converter.mz_to_tof(frame_id, mz_values),
615            TimsIndexConverter::BrukerFormula(converter) => converter.mz_to_tof(frame_id, mz_values),
616        }
617    }
618
619    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
620        match self {
621            TimsIndexConverter::Simple(converter) => {
622                converter.scan_to_inverse_mobility(frame_id, scan_values)
623            }
624            TimsIndexConverter::Calibrated(converter) => {
625                converter.scan_to_inverse_mobility(frame_id, scan_values)
626            }
627            TimsIndexConverter::BrukerLib(converter) => {
628                converter.scan_to_inverse_mobility(frame_id, scan_values)
629            }
630            TimsIndexConverter::Lookup(converter) => {
631                converter.scan_to_inverse_mobility(frame_id, scan_values)
632            }
633            TimsIndexConverter::BrukerFormula(converter) => {
634                converter.scan_to_inverse_mobility(frame_id, scan_values)
635            }
636        }
637    }
638
639    fn inverse_mobility_to_scan(
640        &self,
641        frame_id: u32,
642        inverse_mobility_values: &Vec<f64>,
643    ) -> Vec<u32> {
644        match self {
645            TimsIndexConverter::Simple(converter) => {
646                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
647            }
648            TimsIndexConverter::Calibrated(converter) => {
649                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
650            }
651            TimsIndexConverter::BrukerLib(converter) => {
652                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
653            }
654            TimsIndexConverter::Lookup(converter) => {
655                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
656            }
657            TimsIndexConverter::BrukerFormula(converter) => {
658                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
659            }
660        }
661    }
662}
663
664pub struct TimsLazyLoder {
665    pub raw_data_layout: TimsRawDataLayout,
666    pub index_converter: TimsIndexConverter,
667}
668
669impl TimsData for TimsLazyLoder {
670    fn get_frame(&self, frame_id: u32) -> TimsFrame {
671        let frame_index = (frame_id - 1) as usize;
672
673        // turns out, there can be empty frames in the data, check for that, if so, return an empty frame
674        let num_peaks = self.raw_data_layout.frame_meta_data[frame_index].num_peaks;
675
676        if num_peaks == 0 {
677
678            let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
679
680            let ms_type = match ms_type_raw {
681                0 => MsType::Precursor,
682                8 => MsType::FragmentDda,
683                9 => MsType::FragmentDia,
684                _ => MsType::Unknown,
685            };
686
687            return TimsFrame {
688                frame_id: frame_id as i32,
689                ms_type,
690                scan: Vec::new(),
691                tof: Vec::new(),
692                ims_frame: ImsFrame::new(
693                    self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize].time,
694                    Vec::new(),
695                    Vec::new(),
696                    Vec::new(),
697                ),
698            };
699        }
700
701        let offset = self.raw_data_layout.tims_offset_values[frame_index] as u64;
702
703        let mut file_path = PathBuf::from(&self.raw_data_layout.raw_data_path);
704        file_path.push("analysis.tdf_bin");
705        let mut infile = File::open(&file_path).unwrap();
706
707        infile.seek(SeekFrom::Start(offset)).unwrap();
708
709        let mut bin_buffer = [0u8; 4];
710        infile.read_exact(&mut bin_buffer).unwrap();
711        let bin_size = Cursor::new(bin_buffer).read_i32::<LittleEndian>().unwrap();
712
713        infile.read_exact(&mut bin_buffer).unwrap();
714
715        match self.raw_data_layout.global_meta_data.tims_compression_type {
716            1 => {
717                let scan_count =
718                    self.raw_data_layout.frame_meta_data[frame_index].num_scans as usize;
719                let num_peaks = num_peaks as usize;
720                let compression_offset = 8 + (scan_count + 1) * 4;
721
722                let mut scan_offsets_buffer = vec![0u8; (scan_count + 1) * 4];
723                infile.read_exact(&mut scan_offsets_buffer).unwrap();
724
725                let mut scan_offsets = Vec::with_capacity(scan_count + 1);
726                {
727                    let mut rdr = Cursor::new(&scan_offsets_buffer);
728                    for _ in 0..(scan_count + 1) {
729                        scan_offsets.push(rdr.read_i32::<LittleEndian>().unwrap());
730                    }
731                }
732
733                for offs in &mut scan_offsets {
734                    *offs -= compression_offset as i32;
735                }
736
737                let remaining_size = (bin_size as usize - compression_offset) as usize;
738                let mut compressed_data = vec![0u8; remaining_size];
739                infile.read_exact(&mut compressed_data).unwrap();
740
741                let mut scan_indices_ = vec![0i64; scan_count];
742                let mut tof_indices_ = vec![0u32; num_peaks];
743                let mut intensities_ = vec![0u16; num_peaks];
744
745                let mut scan_start = 0usize;
746
747                for scan_index in 0..scan_count {
748                    let start = scan_offsets[scan_index] as usize;
749                    let end = scan_offsets[scan_index + 1] as usize;
750
751                    if start == end {
752                        continue;
753                    }
754
755                    let max_output_size = num_peaks * 8;
756                    let decompressed_bytes =
757                        lzf_decompress(&compressed_data[start..end], max_output_size)
758                            .expect("LZF decompression failed.");
759
760                    scan_start += parse_decompressed_bruker_binary_type1(
761                        &decompressed_bytes,
762                        &mut scan_indices_,
763                        &mut tof_indices_,
764                        &mut intensities_,
765                        scan_start,
766                        scan_index,
767                    );
768                }
769
770                // Create a flat scan vector to match what flatten_scan_values expects
771                let mut scan = Vec::with_capacity(num_peaks);
772                {
773                    let mut current_scan_index = 0u32;
774                    for &size in &scan_indices_ {
775                        let sz = size as usize;
776                        for _ in 0..sz {
777                            scan.push(current_scan_index);
778                        }
779                        current_scan_index += 1;
780                    }
781                }
782
783                let intensity_dbl = intensities_.iter().map(|&x| x as f64).collect::<Vec<f64>>();
784                let tof_i32 = tof_indices_.iter().map(|&x| x as i32).collect::<Vec<i32>>();
785
786                let mz = self.index_converter.tof_to_mz(frame_id, &tof_indices_);
787                let inv_mobility = self
788                    .index_converter
789                    .scan_to_inverse_mobility(frame_id, &scan);
790
791                let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
792                let ms_type = match ms_type_raw {
793                    0 => MsType::Precursor,
794                    8 => MsType::FragmentDda,
795                    9 => MsType::FragmentDia,
796                    _ => MsType::Unknown,
797                };
798
799                TimsFrame {
800                    frame_id: frame_id as i32,
801                    ms_type,
802                    scan: scan.iter().map(|&x| x as i32).collect(),
803                    tof: tof_i32,
804                    ims_frame: ImsFrame::new(
805                        self.raw_data_layout.frame_meta_data[frame_index].time,
806                        inv_mobility,
807                        mz,
808                        intensity_dbl,
809                    ),
810                }
811            }
812
813            // Existing handling of Type 2
814            2 => {
815                let mut compressed_data = vec![0u8; bin_size as usize - 8];
816                infile.read_exact(&mut compressed_data).unwrap();
817
818                let decompressed_bytes = zstd_decompress(&compressed_data).unwrap();
819
820                let (scan, tof, intensity) =
821                    parse_decompressed_bruker_binary_data(&decompressed_bytes).unwrap();
822                let intensity_dbl = intensity.iter().map(|&x| x as f64).collect();
823                let tof_i32 = tof.iter().map(|&x| x as i32).collect();
824                let scan = flatten_scan_values(&scan, true);
825
826                let mz = self.index_converter.tof_to_mz(frame_id, &tof);
827                let inv_mobility = self
828                    .index_converter
829                    .scan_to_inverse_mobility(frame_id, &scan);
830
831                let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
832
833                let ms_type = match ms_type_raw {
834                    0 => MsType::Precursor,
835                    8 => MsType::FragmentDda,
836                    9 => MsType::FragmentDia,
837                    _ => MsType::Unknown,
838                };
839
840                TimsFrame {
841                    frame_id: frame_id as i32,
842                    ms_type,
843                    scan: scan.iter().map(|&x| x as i32).collect(),
844                    tof: tof_i32,
845                    ims_frame: ImsFrame::new(
846                        self.raw_data_layout.frame_meta_data[frame_index].time,
847                        inv_mobility,
848                        mz,
849                        intensity_dbl,
850                    ),
851                }
852            }
853
854            _ => {
855                panic!("TimsCompressionType is not 1 or 2.")
856            }
857        }
858    }
859
860    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
861        let frame_index = (frame_id - 1) as usize;
862        let offset = self.raw_data_layout.tims_offset_values[frame_index] as u64;
863
864        // turns out, there can be empty frames in the data, check for that, if so, return an empty frame
865        let num_peaks = self.raw_data_layout.frame_meta_data[frame_index].num_peaks;
866
867        if num_peaks == 0 {
868            return RawTimsFrame {
869                frame_id: frame_id as i32,
870                retention_time: self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize].time,
871                ms_type: MsType::Unknown,
872                scan: Vec::new(),
873                tof: Vec::new(),
874                intensity: Vec::new(),
875            };
876        }
877
878        let mut file_path = PathBuf::from(&self.raw_data_layout.raw_data_path);
879        file_path.push("analysis.tdf_bin");
880        let mut infile = File::open(&file_path).unwrap();
881
882        infile.seek(SeekFrom::Start(offset)).unwrap();
883
884        let mut bin_buffer = [0u8; 4];
885        infile.read_exact(&mut bin_buffer).unwrap();
886        let bin_size = Cursor::new(bin_buffer).read_i32::<LittleEndian>().unwrap();
887
888        infile.read_exact(&mut bin_buffer).unwrap();
889
890        match self.raw_data_layout.global_meta_data.tims_compression_type {
891            _ if self.raw_data_layout.global_meta_data.tims_compression_type == 1 => {
892                panic!("Decompression Type1 not implemented.");
893            }
894
895            // Extract from ZSTD compressed binary
896            _ if self.raw_data_layout.global_meta_data.tims_compression_type == 2 => {
897                let mut compressed_data = vec![0u8; bin_size as usize - 8];
898                infile.read_exact(&mut compressed_data).unwrap();
899
900                let decompressed_bytes = zstd_decompress(&compressed_data).unwrap();
901
902                let (scan, tof, intensity) =
903                    parse_decompressed_bruker_binary_data(&decompressed_bytes).unwrap();
904
905                let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
906
907                let ms_type = match ms_type_raw {
908                    0 => MsType::Precursor,
909                    8 => MsType::FragmentDda,
910                    9 => MsType::FragmentDia,
911                    _ => MsType::Unknown,
912                };
913
914                let frame = RawTimsFrame {
915                    frame_id: frame_id as i32,
916                    retention_time: self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize]
917                        .time,
918                    ms_type,
919                    scan,
920                    tof,
921                    intensity: intensity.iter().map(|&x| x as f64).collect(),
922                };
923
924                return frame;
925            }
926
927            // Error on unknown compression algorithm
928            _ => {
929                panic!("TimsCompressionType is not 1 or 2.")
930            }
931        }
932    }
933
934    fn get_slice(&self, frame_ids: Vec<u32>, _num_threads: usize) -> TimsSlice {
935        let result: Vec<TimsFrame> = frame_ids.into_iter().map(|f| self.get_frame(f)).collect();
936
937        TimsSlice { frames: result }
938    }
939
940    fn get_acquisition_mode(&self) -> AcquisitionMode {
941        self.raw_data_layout.acquisition_mode.clone()
942    }
943
944    fn get_frame_count(&self) -> i32 {
945        self.raw_data_layout.frame_meta_data.len() as i32
946    }
947
948    fn get_data_path(&self) -> &str {
949        &self.raw_data_layout.raw_data_path
950    }
951}
952
953pub struct TimsInMemoryLoader {
954    pub raw_data_layout: TimsRawDataLayout,
955    pub index_converter: TimsIndexConverter,
956    compressed_data: Vec<u8>,
957}
958
959impl TimsData for TimsInMemoryLoader {
960    fn get_frame(&self, frame_id: u32) -> TimsFrame {
961        let raw_frame = self.get_raw_frame(frame_id);
962
963        let raw_frame = match raw_frame.ms_type {
964            MsType::FragmentDda => raw_frame.smooth(1).centroid(1),
965            _ => raw_frame,
966        };
967
968        // if raw frame is empty, return an empty frame
969        if raw_frame.scan.is_empty() {
970            return TimsFrame::default();
971        }
972
973        let tof_i32 = raw_frame.tof.iter().map(|&x| x as i32).collect();
974        let scan = flatten_scan_values(&raw_frame.scan, true);
975
976        let mz = self.index_converter.tof_to_mz(frame_id, &raw_frame.tof);
977        let inverse_mobility = self
978            .index_converter
979            .scan_to_inverse_mobility(frame_id, &scan);
980
981        let ims_frame = ImsFrame::new(
982            raw_frame.retention_time,
983            inverse_mobility,
984            mz,
985            raw_frame.intensity,
986        );
987
988        TimsFrame {
989            frame_id: frame_id as i32,
990            ms_type: raw_frame.ms_type,
991            scan: scan.iter().map(|&x| x as i32).collect(),
992            tof: tof_i32,
993            ims_frame,
994        }
995    }
996
997    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
998        let frame_index = (frame_id - 1) as usize;
999        let offset = self.raw_data_layout.tims_offset_values[frame_index] as usize;
1000
1001        let bin_size_offset = offset + 4; // Assuming the size is stored immediately before the frame data
1002        let bin_size = Cursor::new(&self.compressed_data[offset..bin_size_offset])
1003            .read_i32::<LittleEndian>()
1004            .unwrap();
1005
1006        let data_offset = bin_size_offset + 4; // Adjust based on actual structure
1007        let frame_data = &self.compressed_data[data_offset..data_offset + bin_size as usize - 8];
1008
1009        let decompressed_bytes = zstd_decompress(&frame_data).unwrap();
1010
1011        let (scan, tof, intensity) =
1012            parse_decompressed_bruker_binary_data(&decompressed_bytes).unwrap();
1013
1014        let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
1015
1016        let ms_type = match ms_type_raw {
1017            0 => MsType::Precursor,
1018            8 => MsType::FragmentDda,
1019            9 => MsType::FragmentDia,
1020            _ => MsType::Unknown,
1021        };
1022
1023        let raw_frame = RawTimsFrame {
1024            frame_id: frame_id as i32,
1025            retention_time: self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize].time,
1026            ms_type,
1027            scan,
1028            tof,
1029            intensity: intensity.iter().map(|&x| x as f64).collect(),
1030        };
1031
1032        raw_frame
1033    }
1034
1035    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1036        let pool = ThreadPoolBuilder::new()
1037            .num_threads(num_threads)
1038            .build()
1039            .unwrap();
1040        let frames = pool.install(|| {
1041            frame_ids
1042                .par_iter()
1043                .map(|&frame_id| self.get_frame(frame_id))
1044                .collect()
1045        });
1046
1047        TimsSlice { frames }
1048    }
1049
1050    fn get_acquisition_mode(&self) -> AcquisitionMode {
1051        self.raw_data_layout.acquisition_mode.clone()
1052    }
1053
1054    fn get_frame_count(&self) -> i32 {
1055        self.raw_data_layout.frame_meta_data.len() as i32
1056    }
1057
1058    fn get_data_path(&self) -> &str {
1059        &self.raw_data_layout.raw_data_path
1060    }
1061}
1062
1063pub enum TimsDataLoader {
1064    InMemory(TimsInMemoryLoader),
1065    Lazy(TimsLazyLoder),
1066}
1067
1068/// Pick the m/z/mobility index converter, shared by the lazy and in-memory loaders.
1069///
1070/// - `use_bruker_sdk` → try the live Bruker SDK converter; if the SDK shared library can't be
1071///   loaded (missing / wrong arch / mismatched version) this now **falls back** (with a warning)
1072///   instead of panicking.
1073/// - Otherwise (or after that fallback): evaluate the calibration curves stored in the file
1074///   itself ([`BrukerFormulaConverter`]) — no SDK needed, 1/K0 machine-exact and m/z within
1075///   ~1 ppm of the SDK. Only if those tables cannot be read does it fall back to the
1076///   SDK-derived regression, and finally to the simple boundary model (which is ~5 Da off on
1077///   some datasets for m/z and thousands of ppm off on the mobility axis).
1078fn build_index_converter(
1079    bruker_lib_path: &str,
1080    data_path: &str,
1081    use_bruker_sdk: bool,
1082    scan_max_index: u32,
1083    im_lower: f64,
1084    im_upper: f64,
1085    tof_max_index: u32,
1086    mz_lower: f64,
1087    mz_upper: f64,
1088) -> TimsIndexConverter {
1089    if use_bruker_sdk {
1090        match BrukerLibTimsDataConverter::try_new(bruker_lib_path, data_path) {
1091            Ok(converter) => return TimsIndexConverter::BrukerLib(converter),
1092            Err(e) => eprintln!(
1093                "Warning: Bruker SDK requested but failed to load ({e}); \
1094                falling back to derived/simple m/z calibration."
1095            ),
1096        }
1097    }
1098    // SDK-free, and better than anything derived: evaluate the calibration curves the tdf
1099    // carries for this very run (per-frame temperature compensated, exact 1/K0).
1100    match BrukerFormulaConverter::from_d_folder(data_path, 1) {
1101        Ok(converter) => return TimsIndexConverter::BrukerFormula(converter),
1102        Err(e) => eprintln!(
1103            "Warning: could not build the SDK-free Bruker formula converter ({e}); \
1104            falling back to derived/simple m/z calibration."
1105        ),
1106    }
1107
1108    // Derive an accurate calibration via the SDK if possible, else the simple boundary model.
1109    match derive_mz_calibration(bruker_lib_path, data_path, tof_max_index) {
1110        Some((intercept, slope)) => TimsIndexConverter::Calibrated(CalibratedIndexConverter::new(
1111            intercept,
1112            slope,
1113            im_lower,
1114            im_upper,
1115            scan_max_index,
1116        )),
1117        None => {
1118            eprintln!(
1119                "Warning: Could not derive m/z calibration from SDK. \
1120                Using simple boundary model which may have ~5 Da error on some datasets. \
1121                This typically happens on macOS where Bruker SDK is not available."
1122            );
1123            TimsIndexConverter::Simple(SimpleIndexConverter::from_boundaries(
1124                mz_lower,
1125                mz_upper,
1126                tof_max_index,
1127                im_lower,
1128                im_upper,
1129                scan_max_index,
1130            ))
1131        }
1132    }
1133}
1134
1135impl TimsDataLoader {
1136    pub fn new_lazy(
1137        bruker_lib_path: &str,
1138        data_path: &str,
1139        use_bruker_sdk: bool,
1140        scan_max_index: u32,
1141        im_lower: f64,
1142        im_upper: f64,
1143        tof_max_index: u32,
1144        mz_lower: f64,
1145        mz_upper: f64,
1146    ) -> Self {
1147        let raw_data_layout = TimsRawDataLayout::new(data_path);
1148        let index_converter = build_index_converter(
1149            bruker_lib_path,
1150            data_path,
1151            use_bruker_sdk,
1152            scan_max_index,
1153            im_lower,
1154            im_upper,
1155            tof_max_index,
1156            mz_lower,
1157            mz_upper,
1158        );
1159
1160        TimsDataLoader::Lazy(TimsLazyLoder {
1161            raw_data_layout,
1162            index_converter,
1163        })
1164    }
1165
1166    pub fn new_in_memory(
1167        bruker_lib_path: &str,
1168        data_path: &str,
1169        use_bruker_sdk: bool,
1170        scan_max_index: u32,
1171        im_lower: f64,
1172        im_upper: f64,
1173        tof_max_index: u32,
1174        mz_lower: f64,
1175        mz_upper: f64,
1176    ) -> Self {
1177        let raw_data_layout = TimsRawDataLayout::new(data_path);
1178        let index_converter = build_index_converter(
1179            bruker_lib_path,
1180            data_path,
1181            use_bruker_sdk,
1182            scan_max_index,
1183            im_lower,
1184            im_upper,
1185            tof_max_index,
1186            mz_lower,
1187            mz_upper,
1188        );
1189
1190        let mut file_path = PathBuf::from(data_path);
1191        file_path.push("analysis.tdf_bin");
1192        let mut infile = File::open(file_path).unwrap();
1193        let mut data = Vec::new();
1194        infile.read_to_end(&mut data).unwrap();
1195
1196        TimsDataLoader::InMemory(TimsInMemoryLoader {
1197            raw_data_layout,
1198            index_converter,
1199            compressed_data: data,
1200        })
1201    }
1202
1203    /// Create a lazy loader with pre-computed ion mobility calibration lookup table.
1204    ///
1205    /// This method enables accurate ion mobility calibration with fast parallel extraction.
1206    /// The im_lookup table should be pre-computed using the Bruker SDK.
1207    ///
1208    /// # Arguments
1209    /// * `data_path` - Path to the .d folder
1210    /// * `bruker_lib_path` - Path to the Bruker SDK shared library; used to
1211    ///   derive an accurate m/z calibration. Pass "NO_SDK" (or an empty
1212    ///   string) to skip and use the 2-point boundary m/z model.
1213    /// * `tof_max_index` - Maximum TOF index (from GlobalMetaData)
1214    /// * `mz_lower` - Minimum m/z value (from GlobalMetaData)
1215    /// * `mz_upper` - Maximum m/z value (from GlobalMetaData)
1216    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table
1217    ///
1218    /// # Returns
1219    /// A new TimsDataLoader with LookupIndexConverter
1220    pub fn new_lazy_with_calibration(
1221        data_path: &str,
1222        bruker_lib_path: &str,
1223        tof_max_index: u32,
1224        mz_lower: f64,
1225        mz_upper: f64,
1226        im_lookup: Vec<f64>,
1227    ) -> Self {
1228        let raw_data_layout = TimsRawDataLayout::new(data_path);
1229
1230        let index_converter = TimsIndexConverter::Lookup(build_lookup_converter(
1231            bruker_lib_path,
1232            data_path,
1233            tof_max_index,
1234            mz_lower,
1235            mz_upper,
1236            im_lookup,
1237        ));
1238
1239        TimsDataLoader::Lazy(TimsLazyLoder {
1240            raw_data_layout,
1241            index_converter,
1242        })
1243    }
1244
1245    /// Create an in-memory loader with pre-computed ion mobility calibration lookup table.
1246    ///
1247    /// This method enables accurate ion mobility calibration with fast parallel extraction.
1248    /// The im_lookup table should be pre-computed using the Bruker SDK.
1249    ///
1250    /// # Arguments
1251    /// * `data_path` - Path to the .d folder
1252    /// * `bruker_lib_path` - Path to the Bruker SDK shared library; used to
1253    ///   derive an accurate m/z calibration. Pass "NO_SDK" (or an empty
1254    ///   string) to skip and use the 2-point boundary m/z model.
1255    /// * `tof_max_index` - Maximum TOF index (from GlobalMetaData)
1256    /// * `mz_lower` - Minimum m/z value (from GlobalMetaData)
1257    /// * `mz_upper` - Maximum m/z value (from GlobalMetaData)
1258    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table
1259    ///
1260    /// # Returns
1261    /// A new TimsDataLoader with LookupIndexConverter
1262    pub fn new_in_memory_with_calibration(
1263        data_path: &str,
1264        bruker_lib_path: &str,
1265        tof_max_index: u32,
1266        mz_lower: f64,
1267        mz_upper: f64,
1268        im_lookup: Vec<f64>,
1269    ) -> Self {
1270        let raw_data_layout = TimsRawDataLayout::new(data_path);
1271
1272        let index_converter = TimsIndexConverter::Lookup(build_lookup_converter(
1273            bruker_lib_path,
1274            data_path,
1275            tof_max_index,
1276            mz_lower,
1277            mz_upper,
1278            im_lookup,
1279        ));
1280
1281        let mut file_path = PathBuf::from(data_path);
1282        file_path.push("analysis.tdf_bin");
1283        let mut infile = File::open(file_path).unwrap();
1284        let mut data = Vec::new();
1285        infile.read_to_end(&mut data).unwrap();
1286
1287        TimsDataLoader::InMemory(TimsInMemoryLoader {
1288            raw_data_layout,
1289            index_converter,
1290            compressed_data: data,
1291        })
1292    }
1293
1294    /// Create a lazy loader using the exact SDK-free Bruker calibration formulas.
1295    ///
1296    /// Builds a [`BrukerFormulaConverter`] from the `MzCalibration` /
1297    /// `TimsCalibration` tables (frame `calibration_frame_id`, default 1 — the
1298    /// coefficients are near-constant per run). Needs no Bruker SDK at build or
1299    /// runtime; 1/K0 is machine-exact and m/z is bit-exact for MzCalibration
1300    /// ModelType 1 (~1 ppm for ModelType 2).
1301    pub fn new_lazy_with_bruker_formula(data_path: &str, calibration_frame_id: u32) -> Self {
1302        let raw_data_layout = TimsRawDataLayout::new(data_path);
1303        let index_converter = TimsIndexConverter::BrukerFormula(
1304            BrukerFormulaConverter::from_d_folder(data_path, calibration_frame_id).unwrap(),
1305        );
1306        TimsDataLoader::Lazy(TimsLazyLoder {
1307            raw_data_layout,
1308            index_converter,
1309        })
1310    }
1311
1312    /// In-memory counterpart of [`Self::new_lazy_with_bruker_formula`].
1313    pub fn new_in_memory_with_bruker_formula(data_path: &str, calibration_frame_id: u32) -> Self {
1314        let raw_data_layout = TimsRawDataLayout::new(data_path);
1315        let index_converter = TimsIndexConverter::BrukerFormula(
1316            BrukerFormulaConverter::from_d_folder(data_path, calibration_frame_id).unwrap(),
1317        );
1318
1319        let mut file_path = PathBuf::from(data_path);
1320        file_path.push("analysis.tdf_bin");
1321        let mut infile = File::open(file_path).unwrap();
1322        let mut data = Vec::new();
1323        infile.read_to_end(&mut data).unwrap();
1324
1325        TimsDataLoader::InMemory(TimsInMemoryLoader {
1326            raw_data_layout,
1327            index_converter,
1328            compressed_data: data,
1329        })
1330    }
1331
1332    /// Create a lazy loader with full calibration (both m/z and IM).
1333    ///
1334    /// This method uses regression-derived m/z calibration coefficients instead of
1335    /// the simple boundary model, providing more accurate m/z conversion.
1336    ///
1337    /// # Arguments
1338    /// * `data_path` - Path to the .d folder
1339    /// * `tof_intercept` - Intercept for sqrt(mz) = intercept + slope * tof
1340    /// * `tof_slope` - Slope for sqrt(mz) = intercept + slope * tof
1341    /// * `im_min` - Minimum 1/K0 value
1342    /// * `im_max` - Maximum 1/K0 value
1343    /// * `scan_max_index` - Maximum scan index
1344    pub fn new_lazy_with_mz_calibration(
1345        data_path: &str,
1346        tof_intercept: f64,
1347        tof_slope: f64,
1348        im_min: f64,
1349        im_max: f64,
1350        scan_max_index: u32,
1351    ) -> Self {
1352        let raw_data_layout = TimsRawDataLayout::new(data_path);
1353
1354        let index_converter = TimsIndexConverter::Calibrated(CalibratedIndexConverter::new(
1355            tof_intercept,
1356            tof_slope,
1357            im_min,
1358            im_max,
1359            scan_max_index,
1360        ));
1361
1362        TimsDataLoader::Lazy(TimsLazyLoder {
1363            raw_data_layout,
1364            index_converter,
1365        })
1366    }
1367
1368    /// Create an in-memory loader with full calibration (both m/z and IM).
1369    ///
1370    /// This method uses regression-derived m/z calibration coefficients instead of
1371    /// the simple boundary model, providing more accurate m/z conversion.
1372    pub fn new_in_memory_with_mz_calibration(
1373        data_path: &str,
1374        tof_intercept: f64,
1375        tof_slope: f64,
1376        im_min: f64,
1377        im_max: f64,
1378        scan_max_index: u32,
1379    ) -> Self {
1380        let raw_data_layout = TimsRawDataLayout::new(data_path);
1381
1382        let index_converter = TimsIndexConverter::Calibrated(CalibratedIndexConverter::new(
1383            tof_intercept,
1384            tof_slope,
1385            im_min,
1386            im_max,
1387            scan_max_index,
1388        ));
1389
1390        let mut file_path = PathBuf::from(data_path);
1391        file_path.push("analysis.tdf_bin");
1392        let mut infile = File::open(file_path).unwrap();
1393        let mut data = Vec::new();
1394        infile.read_to_end(&mut data).unwrap();
1395
1396        TimsDataLoader::InMemory(TimsInMemoryLoader {
1397            raw_data_layout,
1398            index_converter,
1399            compressed_data: data,
1400        })
1401    }
1402
1403    pub fn get_index_converter(&self) -> &dyn IndexConverter {
1404        match self {
1405            TimsDataLoader::InMemory(loader) => &loader.index_converter,
1406            TimsDataLoader::Lazy(loader) => &loader.index_converter,
1407        }
1408    }
1409
1410    /// Check if the Bruker SDK is being used for index conversion.
1411    /// The Bruker SDK is NOT thread-safe, so parallel operations that call
1412    /// the index converter must be disabled when using the SDK.
1413    pub fn uses_bruker_sdk(&self) -> bool {
1414        match self {
1415            TimsDataLoader::InMemory(loader) => matches!(&loader.index_converter, TimsIndexConverter::BrukerLib(_)),
1416            TimsDataLoader::Lazy(loader) => matches!(&loader.index_converter, TimsIndexConverter::BrukerLib(_)),
1417        }
1418    }
1419}
1420
1421impl TimsData for TimsDataLoader {
1422    fn get_frame(&self, frame_id: u32) -> TimsFrame {
1423        match self {
1424            TimsDataLoader::InMemory(loader) => loader.get_frame(frame_id),
1425            TimsDataLoader::Lazy(loader) => loader.get_frame(frame_id),
1426        }
1427    }
1428    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
1429        match self {
1430            TimsDataLoader::InMemory(loader) => loader.get_raw_frame(frame_id),
1431            TimsDataLoader::Lazy(loader) => loader.get_raw_frame(frame_id),
1432        }
1433    }
1434
1435    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1436        match self {
1437            TimsDataLoader::InMemory(loader) => loader.get_slice(frame_ids, num_threads),
1438            TimsDataLoader::Lazy(loader) => loader.get_slice(frame_ids, num_threads),
1439        }
1440    }
1441
1442    fn get_acquisition_mode(&self) -> AcquisitionMode {
1443        match self {
1444            TimsDataLoader::InMemory(loader) => loader.get_acquisition_mode(),
1445            TimsDataLoader::Lazy(loader) => loader.get_acquisition_mode(),
1446        }
1447    }
1448
1449    fn get_frame_count(&self) -> i32 {
1450        match self {
1451            TimsDataLoader::InMemory(loader) => loader.get_frame_count(),
1452            TimsDataLoader::Lazy(loader) => loader.get_frame_count(),
1453        }
1454    }
1455
1456    fn get_data_path(&self) -> &str {
1457        match self {
1458            TimsDataLoader::InMemory(loader) => loader.get_data_path(),
1459            TimsDataLoader::Lazy(loader) => loader.get_data_path(),
1460        }
1461    }
1462}
1463
1464pub struct SimpleIndexConverter {
1465    pub tof_intercept: f64,
1466    pub tof_slope: f64,
1467    pub scan_intercept: f64,
1468    pub scan_slope: f64,
1469}
1470
1471impl SimpleIndexConverter {
1472    pub fn from_boundaries(
1473        mz_min: f64,
1474        mz_max: f64,
1475        tof_max_index: u32,
1476        im_min: f64,
1477        im_max: f64,
1478        scan_max_index: u32,
1479    ) -> Self {
1480        let tof_intercept: f64 = mz_min.sqrt();
1481        let tof_slope: f64 = (mz_max.sqrt() - tof_intercept) / tof_max_index as f64;
1482
1483        let scan_intercept: f64 = im_max;
1484        let scan_slope: f64 = (im_min - scan_intercept) / scan_max_index as f64;
1485        Self {
1486            tof_intercept,
1487            tof_slope,
1488            scan_intercept,
1489            scan_slope,
1490        }
1491    }
1492}
1493
1494impl IndexConverter for SimpleIndexConverter {
1495    fn tof_to_mz(&self, _frame_id: u32, _tof_values: &Vec<u32>) -> Vec<f64> {
1496        let mut mz_values: Vec<f64> = Vec::new();
1497        mz_values.resize(_tof_values.len(), 0.0);
1498
1499        for (i, &val) in _tof_values.iter().enumerate() {
1500            mz_values[i] = (self.tof_intercept + self.tof_slope * val as f64).powi(2);
1501        }
1502
1503        mz_values
1504    }
1505
1506    fn mz_to_tof(&self, _frame_id: u32, _mz_values: &Vec<f64>) -> Vec<u32> {
1507        let mut tof_values: Vec<u32> = Vec::new();
1508        tof_values.resize(_mz_values.len(), 0);
1509
1510        for (i, &val) in _mz_values.iter().enumerate() {
1511            tof_values[i] = ((val.sqrt() - self.tof_intercept) / self.tof_slope) as u32;
1512        }
1513
1514        tof_values
1515    }
1516
1517    fn scan_to_inverse_mobility(&self, _frame_id: u32, _scan_values: &Vec<u32>) -> Vec<f64> {
1518        let mut inv_mobility_values: Vec<f64> = Vec::new();
1519        inv_mobility_values.resize(_scan_values.len(), 0.0);
1520
1521        for (i, &val) in _scan_values.iter().enumerate() {
1522            inv_mobility_values[i] = self.scan_intercept + self.scan_slope * val as f64;
1523        }
1524
1525        inv_mobility_values
1526    }
1527
1528    fn inverse_mobility_to_scan(
1529        &self,
1530        _frame_id: u32,
1531        _inverse_mobility_values: &Vec<f64>,
1532    ) -> Vec<u32> {
1533        let mut scan_values: Vec<u32> = Vec::new();
1534        scan_values.resize(_inverse_mobility_values.len(), 0);
1535
1536        for (i, &val) in _inverse_mobility_values.iter().enumerate() {
1537            scan_values[i] = ((val - self.scan_intercept) / self.scan_slope) as u32;
1538        }
1539
1540        scan_values
1541    }
1542}
1543
1544/// M/z calibrated index converter using regression-derived coefficients.
1545///
1546/// This provides accurate TOF to m/z conversion without requiring the Bruker SDK
1547/// by using linear regression coefficients derived from known precursor m/z values.
1548///
1549/// The calibration formula is:
1550///   sqrt(mz) = tof_intercept + tof_slope * tof_index
1551///
1552/// This is similar to SimpleIndexConverter but uses externally-provided coefficients
1553/// (e.g., from regression on precursor data) rather than boundary-derived values.
1554pub struct CalibratedIndexConverter {
1555    pub tof_intercept: f64,
1556    pub tof_slope: f64,
1557    pub scan_intercept: f64,
1558    pub scan_slope: f64,
1559}
1560
1561impl CalibratedIndexConverter {
1562    /// Create a new calibrated converter with regression-derived coefficients.
1563    ///
1564    /// # Arguments
1565    /// * `tof_intercept` - Intercept for sqrt(mz) = intercept + slope * tof
1566    /// * `tof_slope` - Slope for sqrt(mz) = intercept + slope * tof
1567    /// * `im_min` - Minimum 1/K0 value
1568    /// * `im_max` - Maximum 1/K0 value
1569    /// * `scan_max_index` - Maximum scan index
1570    pub fn new(
1571        tof_intercept: f64,
1572        tof_slope: f64,
1573        im_min: f64,
1574        im_max: f64,
1575        scan_max_index: u32,
1576    ) -> Self {
1577        let scan_intercept = im_max;
1578        let scan_slope = (im_min - scan_intercept) / scan_max_index as f64;
1579        Self {
1580            tof_intercept,
1581            tof_slope,
1582            scan_intercept,
1583            scan_slope,
1584        }
1585    }
1586}
1587
1588impl IndexConverter for CalibratedIndexConverter {
1589    fn tof_to_mz(&self, _frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1590        let mut mz_values: Vec<f64> = Vec::with_capacity(tof_values.len());
1591
1592        for &tof_index in tof_values.iter() {
1593            // sqrt(mz) = tof_intercept + tof_slope * tof_index
1594            let sqrt_mz = self.tof_intercept + self.tof_slope * tof_index as f64;
1595            mz_values.push(sqrt_mz * sqrt_mz);
1596        }
1597
1598        mz_values
1599    }
1600
1601    fn mz_to_tof(&self, _frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1602        let mut tof_values: Vec<u32> = Vec::with_capacity(mz_values.len());
1603
1604        for &mz in mz_values.iter() {
1605            let sqrt_mz = mz.sqrt();
1606            // tof_index = (sqrt(mz) - tof_intercept) / tof_slope
1607            tof_values.push(((sqrt_mz - self.tof_intercept) / self.tof_slope) as u32);
1608        }
1609
1610        tof_values
1611    }
1612
1613    fn scan_to_inverse_mobility(&self, _frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1614        let mut inv_mobility_values: Vec<f64> = Vec::with_capacity(scan_values.len());
1615
1616        for &val in scan_values.iter() {
1617            inv_mobility_values.push(self.scan_intercept + self.scan_slope * val as f64);
1618        }
1619
1620        inv_mobility_values
1621    }
1622
1623    fn inverse_mobility_to_scan(
1624        &self,
1625        _frame_id: u32,
1626        inverse_mobility_values: &Vec<f64>,
1627    ) -> Vec<u32> {
1628        let mut scan_values: Vec<u32> = Vec::with_capacity(inverse_mobility_values.len());
1629
1630        for &val in inverse_mobility_values.iter() {
1631            scan_values.push(((val - self.scan_intercept) / self.scan_slope) as u32);
1632        }
1633
1634        scan_values
1635    }
1636}
1637
1638/// Ion mobility index converter using pre-computed lookup table.
1639///
1640/// This converter uses a pre-computed scan→1/K0 lookup table extracted from the Bruker SDK.
1641/// It enables accurate ion mobility calibration with fast parallel extraction.
1642///
1643/// Background:
1644/// - The Bruker calibration formula is patented and proprietary
1645/// - Using the Bruker SDK gives accurate values but is slow (not thread-safe)
1646/// - Linear interpolation is fast but inaccurate
1647/// - This converter uses SDK-probed lookup for accuracy with O(1) thread-safe lookups
1648///
1649/// The lookup table is typically small (~8KB for 1000 scans) and constant across all frames.
1650pub struct LookupIndexConverter {
1651    // m/z conversion uses simple linear model (accurate enough for most purposes)
1652    pub tof_intercept: f64,
1653    pub tof_slope: f64,
1654
1655    // Ion mobility: pre-computed lookup table from Bruker SDK
1656    // scan_index → 1/K0 value
1657    pub im_lookup: Vec<f64>,
1658
1659    // Fallback for inverse conversion (1/K0 → scan)
1660    // We store the min/max for binary search bounds
1661    pub im_min: f64,
1662    pub im_max: f64,
1663}
1664
1665impl LookupIndexConverter {
1666    /// Create a new LookupIndexConverter with pre-computed ion mobility lookup.
1667    ///
1668    /// # Arguments
1669    /// * `mz_min` - Minimum m/z value for TOF conversion
1670    /// * `mz_max` - Maximum m/z value for TOF conversion
1671    /// * `tof_max_index` - Maximum TOF index
1672    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table from Bruker SDK
1673    ///
1674    /// # Returns
1675    /// A new LookupIndexConverter instance
1676    pub fn new(
1677        mz_min: f64,
1678        mz_max: f64,
1679        tof_max_index: u32,
1680        im_lookup: Vec<f64>,
1681    ) -> Self {
1682        let tof_intercept: f64 = mz_min.sqrt();
1683        let tof_slope: f64 = (mz_max.sqrt() - tof_intercept) / tof_max_index as f64;
1684
1685        // Get IM bounds for inverse conversion
1686        let im_min = im_lookup.iter().cloned().fold(f64::INFINITY, f64::min);
1687        let im_max = im_lookup.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1688
1689        Self {
1690            tof_intercept,
1691            tof_slope,
1692            im_lookup,
1693            im_min,
1694            im_max,
1695        }
1696    }
1697
1698    /// Create a `LookupIndexConverter` with regression-derived m/z coefficients.
1699    ///
1700    /// Uses `sqrt(mz) = tof_intercept + tof_slope * tof` with coefficients fitted
1701    /// from the Bruker SDK (see `derive_mz_calibration`), instead of the 2-point
1702    /// boundary model in `new`. Preferred whenever the SDK is available.
1703    ///
1704    /// # Arguments
1705    /// * `tof_intercept` - Intercept of the sqrt(mz)-vs-tof regression
1706    /// * `tof_slope` - Slope of the sqrt(mz)-vs-tof regression
1707    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table from Bruker SDK
1708    pub fn with_mz_fit(tof_intercept: f64, tof_slope: f64, im_lookup: Vec<f64>) -> Self {
1709        // Get IM bounds for inverse conversion
1710        let im_min = im_lookup.iter().cloned().fold(f64::INFINITY, f64::min);
1711        let im_max = im_lookup.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1712
1713        Self {
1714            tof_intercept,
1715            tof_slope,
1716            im_lookup,
1717            im_min,
1718            im_max,
1719        }
1720    }
1721}
1722
1723impl IndexConverter for LookupIndexConverter {
1724    fn tof_to_mz(&self, _frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1725        let mut mz_values: Vec<f64> = Vec::new();
1726        mz_values.resize(tof_values.len(), 0.0);
1727
1728        for (i, &val) in tof_values.iter().enumerate() {
1729            mz_values[i] = (self.tof_intercept + self.tof_slope * val as f64).powi(2);
1730        }
1731
1732        mz_values
1733    }
1734
1735    fn mz_to_tof(&self, _frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1736        let mut tof_values: Vec<u32> = Vec::new();
1737        tof_values.resize(mz_values.len(), 0);
1738
1739        for (i, &val) in mz_values.iter().enumerate() {
1740            tof_values[i] = ((val.sqrt() - self.tof_intercept) / self.tof_slope) as u32;
1741        }
1742
1743        tof_values
1744    }
1745
1746    fn scan_to_inverse_mobility(&self, _frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1747        // Use the pre-computed lookup table for O(1) conversion
1748        scan_values
1749            .iter()
1750            .map(|&s| {
1751                self.im_lookup
1752                    .get(s as usize)
1753                    .copied()
1754                    .unwrap_or(f64::NAN)
1755            })
1756            .collect()
1757    }
1758
1759    fn inverse_mobility_to_scan(
1760        &self,
1761        _frame_id: u32,
1762        inverse_mobility_values: &Vec<f64>,
1763    ) -> Vec<u32> {
1764        // Use binary search to find the closest scan index for each 1/K0 value
1765        // The lookup table is monotonically decreasing (higher scan = lower 1/K0)
1766        inverse_mobility_values
1767            .iter()
1768            .map(|&im| {
1769                if im.is_nan() || self.im_lookup.is_empty() {
1770                    return 0;
1771                }
1772
1773                // Binary search for the closest value
1774                // Note: im_lookup is typically monotonically decreasing
1775                let mut best_scan = 0usize;
1776                let mut best_diff = f64::INFINITY;
1777
1778                for (scan, &lookup_im) in self.im_lookup.iter().enumerate() {
1779                    let diff = (lookup_im - im).abs();
1780                    if diff < best_diff {
1781                        best_diff = diff;
1782                        best_scan = scan;
1783                    }
1784                }
1785
1786                best_scan as u32
1787            })
1788            .collect()
1789    }
1790}