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    /// True only when substituting this converter for the Bruker SDK cannot change a single
532    /// output value, which is the only condition under which it is safe to swap in.
533    ///
534    /// Two things have to hold for **every** frame in the run:
535    ///   * its `MzCalibration` row is `ModelType` 1 — the only m/z model this converter reproduces
536    ///     bit-exactly. ModelType 2 shares the C0/C1/C2 curve but omits the proprietary C8..C14
537    ///     fine correction, landing ~2 ppm away; measured on a real ModelType-2 run that moves
538    ///     **11.5 % of arbitrary m/z values into a neighbouring TOF bin**, which would change the
539    ///     `(scan, tof)` dedup and therefore the bytes written to `analysis.tdf_bin`.
540    ///   * it resolves to a real calibration row. Frames with NULL `T1`/`T2`, a missing `Frames`
541    ///     row, or a link to an unsupported calibration row silently fall back to the default
542    ///     calibration in `mz_for`/`im_for`, and the SDK does no such thing.
543    ///
544    /// Callers that cannot use a non-exact converter must fall back to the SDK, serially.
545    pub fn is_sdk_exact(&self) -> bool {
546        if self.frames.is_empty() {
547            return false;
548        }
549        self.frames.values().all(|f| {
550            self.mz_rows.get(&f.mz_id).map(|r| r.model_type == 1).unwrap_or(false)
551                && self.im_rows.contains_key(&f.tims_id)
552        })
553    }
554
555    /// Spread (max - min, in degC) of `Frames.T1` and `Frames.T2` across the run.
556    ///
557    /// A near-zero spread means per-frame and fixed-frame calibration agree; a
558    /// large one means the digitizer temperature moved and per-frame
559    /// compensation is doing real work (~20 ppm of m/z per degC).
560    pub fn temperature_spread(&self) -> (f64, f64) {
561        self.temp_spread
562    }
563
564    /// m/z calibrator for `frame_id`, falling back to the default calibration.
565    fn mz_for(&self, frame_id: u32) -> crate::data::calibration::MzCalibrator {
566        self.frames
567            .get(&frame_id)
568            .and_then(|f| {
569                self.mz_rows.get(&f.mz_id).map(|row| {
570                    crate::data::calibration::MzCalibrator::from_calibration(row, f.t1, f.t2)
571                })
572            })
573            .unwrap_or_else(|| self.mz.clone())
574    }
575
576    /// Mobility calibrator for `frame_id`, falling back to the default one.
577    fn im_for(&self, frame_id: u32) -> &crate::data::calibration::MobilityCalibrator {
578        self.frames
579            .get(&frame_id)
580            .and_then(|f| self.im_rows.get(&f.tims_id))
581            .unwrap_or(&self.im)
582    }
583}
584
585impl IndexConverter for BrukerFormulaConverter {
586    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
587        let mz = self.mz_for(frame_id);
588        tof_values.iter().map(|&t| mz.tof_to_mz(t)).collect()
589    }
590    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
591        let mz = self.mz_for(frame_id);
592        mz_values.iter().map(|&m| mz.mz_to_tof(m)).collect()
593    }
594    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
595        let im = self.im_for(frame_id);
596        scan_values
597            .iter()
598            .map(|&s| im.scan_to_one_over_k0(s))
599            .collect()
600    }
601    fn inverse_mobility_to_scan(
602        &self,
603        frame_id: u32,
604        inverse_mobility_values: &Vec<f64>,
605    ) -> Vec<u32> {
606        let im = self.im_for(frame_id);
607        inverse_mobility_values
608            .iter()
609            .map(|&v| im.one_over_k0_to_scan(v))
610            .collect()
611    }
612}
613
614pub enum TimsIndexConverter {
615    Simple(SimpleIndexConverter),
616    Calibrated(CalibratedIndexConverter),
617    BrukerLib(BrukerLibTimsDataConverter),
618    Lookup(LookupIndexConverter),
619    BrukerFormula(BrukerFormulaConverter),
620}
621
622impl IndexConverter for TimsIndexConverter {
623    fn tof_to_mz(&self, frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
624        match self {
625            TimsIndexConverter::Simple(converter) => converter.tof_to_mz(frame_id, tof_values),
626            TimsIndexConverter::Calibrated(converter) => converter.tof_to_mz(frame_id, tof_values),
627            TimsIndexConverter::BrukerLib(converter) => converter.tof_to_mz(frame_id, tof_values),
628            TimsIndexConverter::Lookup(converter) => converter.tof_to_mz(frame_id, tof_values),
629            TimsIndexConverter::BrukerFormula(converter) => converter.tof_to_mz(frame_id, tof_values),
630        }
631    }
632
633    fn mz_to_tof(&self, frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
634        match self {
635            TimsIndexConverter::Simple(converter) => converter.mz_to_tof(frame_id, mz_values),
636            TimsIndexConverter::Calibrated(converter) => converter.mz_to_tof(frame_id, mz_values),
637            TimsIndexConverter::BrukerLib(converter) => converter.mz_to_tof(frame_id, mz_values),
638            TimsIndexConverter::Lookup(converter) => converter.mz_to_tof(frame_id, mz_values),
639            TimsIndexConverter::BrukerFormula(converter) => converter.mz_to_tof(frame_id, mz_values),
640        }
641    }
642
643    fn scan_to_inverse_mobility(&self, frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
644        match self {
645            TimsIndexConverter::Simple(converter) => {
646                converter.scan_to_inverse_mobility(frame_id, scan_values)
647            }
648            TimsIndexConverter::Calibrated(converter) => {
649                converter.scan_to_inverse_mobility(frame_id, scan_values)
650            }
651            TimsIndexConverter::BrukerLib(converter) => {
652                converter.scan_to_inverse_mobility(frame_id, scan_values)
653            }
654            TimsIndexConverter::Lookup(converter) => {
655                converter.scan_to_inverse_mobility(frame_id, scan_values)
656            }
657            TimsIndexConverter::BrukerFormula(converter) => {
658                converter.scan_to_inverse_mobility(frame_id, scan_values)
659            }
660        }
661    }
662
663    fn inverse_mobility_to_scan(
664        &self,
665        frame_id: u32,
666        inverse_mobility_values: &Vec<f64>,
667    ) -> Vec<u32> {
668        match self {
669            TimsIndexConverter::Simple(converter) => {
670                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
671            }
672            TimsIndexConverter::Calibrated(converter) => {
673                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
674            }
675            TimsIndexConverter::BrukerLib(converter) => {
676                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
677            }
678            TimsIndexConverter::Lookup(converter) => {
679                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
680            }
681            TimsIndexConverter::BrukerFormula(converter) => {
682                converter.inverse_mobility_to_scan(frame_id, inverse_mobility_values)
683            }
684        }
685    }
686}
687
688pub struct TimsLazyLoder {
689    pub raw_data_layout: TimsRawDataLayout,
690    pub index_converter: TimsIndexConverter,
691}
692
693impl TimsLazyLoder {
694    /// Same as [`TimsData::get_frame`] but converts tof/scan with an explicit converter instead of
695    /// `self.index_converter`. Parallel readers use this to bypass the Bruker SDK, whose
696    /// `tims_index_to_mz` is not safe to call concurrently on one handle.
697    pub fn get_frame_with_converter(&self, frame_id: u32, conv: &dyn IndexConverter) -> TimsFrame {
698        let frame_index = (frame_id - 1) as usize;
699
700        // turns out, there can be empty frames in the data, check for that, if so, return an empty frame
701        let num_peaks = self.raw_data_layout.frame_meta_data[frame_index].num_peaks;
702
703        if num_peaks == 0 {
704
705            let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
706
707            let ms_type = match ms_type_raw {
708                0 => MsType::Precursor,
709                8 => MsType::FragmentDda,
710                9 => MsType::FragmentDia,
711                _ => MsType::Unknown,
712            };
713
714            return TimsFrame {
715                frame_id: frame_id as i32,
716                ms_type,
717                scan: Vec::new(),
718                tof: Vec::new(),
719                ims_frame: ImsFrame::new(
720                    self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize].time,
721                    Vec::new(),
722                    Vec::new(),
723                    Vec::new(),
724                ),
725            };
726        }
727
728        let offset = self.raw_data_layout.tims_offset_values[frame_index] as u64;
729
730        let mut file_path = PathBuf::from(&self.raw_data_layout.raw_data_path);
731        file_path.push("analysis.tdf_bin");
732        let mut infile = File::open(&file_path).unwrap();
733
734        infile.seek(SeekFrom::Start(offset)).unwrap();
735
736        let mut bin_buffer = [0u8; 4];
737        infile.read_exact(&mut bin_buffer).unwrap();
738        let bin_size = Cursor::new(bin_buffer).read_i32::<LittleEndian>().unwrap();
739
740        infile.read_exact(&mut bin_buffer).unwrap();
741
742        match self.raw_data_layout.global_meta_data.tims_compression_type {
743            1 => {
744                let scan_count =
745                    self.raw_data_layout.frame_meta_data[frame_index].num_scans as usize;
746                let num_peaks = num_peaks as usize;
747                let compression_offset = 8 + (scan_count + 1) * 4;
748
749                let mut scan_offsets_buffer = vec![0u8; (scan_count + 1) * 4];
750                infile.read_exact(&mut scan_offsets_buffer).unwrap();
751
752                let mut scan_offsets = Vec::with_capacity(scan_count + 1);
753                {
754                    let mut rdr = Cursor::new(&scan_offsets_buffer);
755                    for _ in 0..(scan_count + 1) {
756                        scan_offsets.push(rdr.read_i32::<LittleEndian>().unwrap());
757                    }
758                }
759
760                for offs in &mut scan_offsets {
761                    *offs -= compression_offset as i32;
762                }
763
764                let remaining_size = (bin_size as usize - compression_offset) as usize;
765                let mut compressed_data = vec![0u8; remaining_size];
766                infile.read_exact(&mut compressed_data).unwrap();
767
768                let mut scan_indices_ = vec![0i64; scan_count];
769                let mut tof_indices_ = vec![0u32; num_peaks];
770                let mut intensities_ = vec![0u16; num_peaks];
771
772                let mut scan_start = 0usize;
773
774                for scan_index in 0..scan_count {
775                    let start = scan_offsets[scan_index] as usize;
776                    let end = scan_offsets[scan_index + 1] as usize;
777
778                    if start == end {
779                        continue;
780                    }
781
782                    let max_output_size = num_peaks * 8;
783                    let decompressed_bytes =
784                        lzf_decompress(&compressed_data[start..end], max_output_size)
785                            .expect("LZF decompression failed.");
786
787                    scan_start += parse_decompressed_bruker_binary_type1(
788                        &decompressed_bytes,
789                        &mut scan_indices_,
790                        &mut tof_indices_,
791                        &mut intensities_,
792                        scan_start,
793                        scan_index,
794                    );
795                }
796
797                // Create a flat scan vector to match what flatten_scan_values expects
798                let mut scan = Vec::with_capacity(num_peaks);
799                {
800                    let mut current_scan_index = 0u32;
801                    for &size in &scan_indices_ {
802                        let sz = size as usize;
803                        for _ in 0..sz {
804                            scan.push(current_scan_index);
805                        }
806                        current_scan_index += 1;
807                    }
808                }
809
810                let intensity_dbl = intensities_.iter().map(|&x| x as f64).collect::<Vec<f64>>();
811                let tof_i32 = tof_indices_.iter().map(|&x| x as i32).collect::<Vec<i32>>();
812
813                let mz = conv.tof_to_mz(frame_id, &tof_indices_);
814                let inv_mobility = conv.scan_to_inverse_mobility(frame_id, &scan);
815
816                let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
817                let ms_type = match ms_type_raw {
818                    0 => MsType::Precursor,
819                    8 => MsType::FragmentDda,
820                    9 => MsType::FragmentDia,
821                    _ => MsType::Unknown,
822                };
823
824                TimsFrame {
825                    frame_id: frame_id as i32,
826                    ms_type,
827                    scan: scan.iter().map(|&x| x as i32).collect(),
828                    tof: tof_i32,
829                    ims_frame: ImsFrame::new(
830                        self.raw_data_layout.frame_meta_data[frame_index].time,
831                        inv_mobility,
832                        mz,
833                        intensity_dbl,
834                    ),
835                }
836            }
837
838            // Existing handling of Type 2
839            2 => {
840                let mut compressed_data = vec![0u8; bin_size as usize - 8];
841                infile.read_exact(&mut compressed_data).unwrap();
842
843                let decompressed_bytes = zstd_decompress(&compressed_data).unwrap();
844
845                let (scan, tof, intensity) =
846                    parse_decompressed_bruker_binary_data(&decompressed_bytes).unwrap();
847                let intensity_dbl = intensity.iter().map(|&x| x as f64).collect();
848                let tof_i32 = tof.iter().map(|&x| x as i32).collect();
849                let scan = flatten_scan_values(&scan, true);
850
851                let mz = conv.tof_to_mz(frame_id, &tof);
852                let inv_mobility = conv.scan_to_inverse_mobility(frame_id, &scan);
853
854                let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
855
856                let ms_type = match ms_type_raw {
857                    0 => MsType::Precursor,
858                    8 => MsType::FragmentDda,
859                    9 => MsType::FragmentDia,
860                    _ => MsType::Unknown,
861                };
862
863                TimsFrame {
864                    frame_id: frame_id as i32,
865                    ms_type,
866                    scan: scan.iter().map(|&x| x as i32).collect(),
867                    tof: tof_i32,
868                    ims_frame: ImsFrame::new(
869                        self.raw_data_layout.frame_meta_data[frame_index].time,
870                        inv_mobility,
871                        mz,
872                        intensity_dbl,
873                    ),
874                }
875            }
876
877            _ => {
878                panic!("TimsCompressionType is not 1 or 2.")
879            }
880        }
881    }
882}
883
884impl TimsData for TimsLazyLoder {
885    fn get_frame(&self, frame_id: u32) -> TimsFrame {
886        self.get_frame_with_converter(frame_id, &self.index_converter)
887    }
888
889    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
890        let frame_index = (frame_id - 1) as usize;
891        let offset = self.raw_data_layout.tims_offset_values[frame_index] as u64;
892
893        // turns out, there can be empty frames in the data, check for that, if so, return an empty frame
894        let num_peaks = self.raw_data_layout.frame_meta_data[frame_index].num_peaks;
895
896        if num_peaks == 0 {
897            return RawTimsFrame {
898                frame_id: frame_id as i32,
899                retention_time: self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize].time,
900                ms_type: MsType::Unknown,
901                scan: Vec::new(),
902                tof: Vec::new(),
903                intensity: Vec::new(),
904            };
905        }
906
907        let mut file_path = PathBuf::from(&self.raw_data_layout.raw_data_path);
908        file_path.push("analysis.tdf_bin");
909        let mut infile = File::open(&file_path).unwrap();
910
911        infile.seek(SeekFrom::Start(offset)).unwrap();
912
913        let mut bin_buffer = [0u8; 4];
914        infile.read_exact(&mut bin_buffer).unwrap();
915        let bin_size = Cursor::new(bin_buffer).read_i32::<LittleEndian>().unwrap();
916
917        infile.read_exact(&mut bin_buffer).unwrap();
918
919        match self.raw_data_layout.global_meta_data.tims_compression_type {
920            _ if self.raw_data_layout.global_meta_data.tims_compression_type == 1 => {
921                panic!("Decompression Type1 not implemented.");
922            }
923
924            // Extract from ZSTD compressed binary
925            _ if self.raw_data_layout.global_meta_data.tims_compression_type == 2 => {
926                let mut compressed_data = vec![0u8; bin_size as usize - 8];
927                infile.read_exact(&mut compressed_data).unwrap();
928
929                let decompressed_bytes = zstd_decompress(&compressed_data).unwrap();
930
931                let (scan, tof, intensity) =
932                    parse_decompressed_bruker_binary_data(&decompressed_bytes).unwrap();
933
934                let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
935
936                let ms_type = match ms_type_raw {
937                    0 => MsType::Precursor,
938                    8 => MsType::FragmentDda,
939                    9 => MsType::FragmentDia,
940                    _ => MsType::Unknown,
941                };
942
943                let frame = RawTimsFrame {
944                    frame_id: frame_id as i32,
945                    retention_time: self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize]
946                        .time,
947                    ms_type,
948                    scan,
949                    tof,
950                    intensity: intensity.iter().map(|&x| x as f64).collect(),
951                };
952
953                return frame;
954            }
955
956            // Error on unknown compression algorithm
957            _ => {
958                panic!("TimsCompressionType is not 1 or 2.")
959            }
960        }
961    }
962
963    fn get_slice(&self, frame_ids: Vec<u32>, _num_threads: usize) -> TimsSlice {
964        let result: Vec<TimsFrame> = frame_ids.into_iter().map(|f| self.get_frame(f)).collect();
965
966        TimsSlice { frames: result }
967    }
968
969    fn get_acquisition_mode(&self) -> AcquisitionMode {
970        self.raw_data_layout.acquisition_mode.clone()
971    }
972
973    fn get_frame_count(&self) -> i32 {
974        self.raw_data_layout.frame_meta_data.len() as i32
975    }
976
977    fn get_data_path(&self) -> &str {
978        &self.raw_data_layout.raw_data_path
979    }
980}
981
982pub struct TimsInMemoryLoader {
983    pub raw_data_layout: TimsRawDataLayout,
984    pub index_converter: TimsIndexConverter,
985    compressed_data: Vec<u8>,
986}
987
988impl TimsInMemoryLoader {
989    /// Same as [`TimsData::get_frame`] but converts tof/scan with an explicit converter instead of
990    /// `self.index_converter`. Parallel readers use this to bypass the Bruker SDK, whose
991    /// `tims_index_to_mz` is not safe to call concurrently on one handle.
992    pub fn get_frame_with_converter(&self, frame_id: u32, conv: &dyn IndexConverter) -> TimsFrame {
993        let raw_frame = self.get_raw_frame(frame_id);
994
995        let raw_frame = match raw_frame.ms_type {
996            MsType::FragmentDda => raw_frame.smooth(1).centroid(1),
997            _ => raw_frame,
998        };
999
1000        // if raw frame is empty, return an empty frame
1001        if raw_frame.scan.is_empty() {
1002            return TimsFrame::default();
1003        }
1004
1005        let tof_i32 = raw_frame.tof.iter().map(|&x| x as i32).collect();
1006        let scan = flatten_scan_values(&raw_frame.scan, true);
1007
1008        let mz = conv.tof_to_mz(frame_id, &raw_frame.tof);
1009        let inverse_mobility = conv.scan_to_inverse_mobility(frame_id, &scan);
1010
1011        let ims_frame = ImsFrame::new(
1012            raw_frame.retention_time,
1013            inverse_mobility,
1014            mz,
1015            raw_frame.intensity,
1016        );
1017
1018        TimsFrame {
1019            frame_id: frame_id as i32,
1020            ms_type: raw_frame.ms_type,
1021            scan: scan.iter().map(|&x| x as i32).collect(),
1022            tof: tof_i32,
1023            ims_frame,
1024        }
1025    }
1026}
1027
1028impl TimsData for TimsInMemoryLoader {
1029    fn get_frame(&self, frame_id: u32) -> TimsFrame {
1030        self.get_frame_with_converter(frame_id, &self.index_converter)
1031    }
1032
1033    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
1034        let frame_index = (frame_id - 1) as usize;
1035        let offset = self.raw_data_layout.tims_offset_values[frame_index] as usize;
1036
1037        let bin_size_offset = offset + 4; // Assuming the size is stored immediately before the frame data
1038        let bin_size = Cursor::new(&self.compressed_data[offset..bin_size_offset])
1039            .read_i32::<LittleEndian>()
1040            .unwrap();
1041
1042        let data_offset = bin_size_offset + 4; // Adjust based on actual structure
1043        let frame_data = &self.compressed_data[data_offset..data_offset + bin_size as usize - 8];
1044
1045        let decompressed_bytes = zstd_decompress(&frame_data).unwrap();
1046
1047        let (scan, tof, intensity) =
1048            parse_decompressed_bruker_binary_data(&decompressed_bytes).unwrap();
1049
1050        let ms_type_raw = self.raw_data_layout.frame_meta_data[frame_index].ms_ms_type;
1051
1052        let ms_type = match ms_type_raw {
1053            0 => MsType::Precursor,
1054            8 => MsType::FragmentDda,
1055            9 => MsType::FragmentDia,
1056            _ => MsType::Unknown,
1057        };
1058
1059        let raw_frame = RawTimsFrame {
1060            frame_id: frame_id as i32,
1061            retention_time: self.raw_data_layout.frame_meta_data[(frame_id - 1) as usize].time,
1062            ms_type,
1063            scan,
1064            tof,
1065            intensity: intensity.iter().map(|&x| x as f64).collect(),
1066        };
1067
1068        raw_frame
1069    }
1070
1071    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1072        let pool = ThreadPoolBuilder::new()
1073            .num_threads(num_threads)
1074            .build()
1075            .unwrap();
1076        let frames = pool.install(|| {
1077            frame_ids
1078                .par_iter()
1079                .map(|&frame_id| self.get_frame(frame_id))
1080                .collect()
1081        });
1082
1083        TimsSlice { frames }
1084    }
1085
1086    fn get_acquisition_mode(&self) -> AcquisitionMode {
1087        self.raw_data_layout.acquisition_mode.clone()
1088    }
1089
1090    fn get_frame_count(&self) -> i32 {
1091        self.raw_data_layout.frame_meta_data.len() as i32
1092    }
1093
1094    fn get_data_path(&self) -> &str {
1095        &self.raw_data_layout.raw_data_path
1096    }
1097}
1098
1099pub enum TimsDataLoader {
1100    InMemory(TimsInMemoryLoader),
1101    Lazy(TimsLazyLoder),
1102}
1103
1104/// Pick the m/z/mobility index converter, shared by the lazy and in-memory loaders.
1105///
1106/// - `use_bruker_sdk` → try the live Bruker SDK converter; if the SDK shared library can't be
1107///   loaded (missing / wrong arch / mismatched version) this now **falls back** (with a warning)
1108///   instead of panicking.
1109/// - Otherwise (or after that fallback): evaluate the calibration curves stored in the file
1110///   itself ([`BrukerFormulaConverter`]) — no SDK needed, 1/K0 machine-exact and m/z within
1111///   ~1 ppm of the SDK. Only if those tables cannot be read does it fall back to the
1112///   SDK-derived regression, and finally to the simple boundary model (which is ~5 Da off on
1113///   some datasets for m/z and thousands of ppm off on the mobility axis).
1114fn build_index_converter(
1115    bruker_lib_path: &str,
1116    data_path: &str,
1117    use_bruker_sdk: bool,
1118    scan_max_index: u32,
1119    im_lower: f64,
1120    im_upper: f64,
1121    tof_max_index: u32,
1122    mz_lower: f64,
1123    mz_upper: f64,
1124) -> TimsIndexConverter {
1125    if use_bruker_sdk {
1126        match BrukerLibTimsDataConverter::try_new(bruker_lib_path, data_path) {
1127            Ok(converter) => return TimsIndexConverter::BrukerLib(converter),
1128            Err(e) => eprintln!(
1129                "Warning: Bruker SDK requested but failed to load ({e}); \
1130                falling back to derived/simple m/z calibration."
1131            ),
1132        }
1133    }
1134    // SDK-free, and better than anything derived: evaluate the calibration curves the tdf
1135    // carries for this very run (per-frame temperature compensated, exact 1/K0).
1136    match BrukerFormulaConverter::from_d_folder(data_path, 1) {
1137        Ok(converter) => return TimsIndexConverter::BrukerFormula(converter),
1138        Err(e) => eprintln!(
1139            "Warning: could not build the SDK-free Bruker formula converter ({e}); \
1140            falling back to derived/simple m/z calibration."
1141        ),
1142    }
1143
1144    // Derive an accurate calibration via the SDK if possible, else the simple boundary model.
1145    match derive_mz_calibration(bruker_lib_path, data_path, tof_max_index) {
1146        Some((intercept, slope)) => TimsIndexConverter::Calibrated(CalibratedIndexConverter::new(
1147            intercept,
1148            slope,
1149            im_lower,
1150            im_upper,
1151            scan_max_index,
1152        )),
1153        None => {
1154            eprintln!(
1155                "Warning: Could not derive m/z calibration from SDK. \
1156                Using simple boundary model which may have ~5 Da error on some datasets. \
1157                This typically happens on macOS where Bruker SDK is not available."
1158            );
1159            TimsIndexConverter::Simple(SimpleIndexConverter::from_boundaries(
1160                mz_lower,
1161                mz_upper,
1162                tof_max_index,
1163                im_lower,
1164                im_upper,
1165                scan_max_index,
1166            ))
1167        }
1168    }
1169}
1170
1171impl TimsDataLoader {
1172    pub fn new_lazy(
1173        bruker_lib_path: &str,
1174        data_path: &str,
1175        use_bruker_sdk: bool,
1176        scan_max_index: u32,
1177        im_lower: f64,
1178        im_upper: f64,
1179        tof_max_index: u32,
1180        mz_lower: f64,
1181        mz_upper: f64,
1182    ) -> Self {
1183        let raw_data_layout = TimsRawDataLayout::new(data_path);
1184        let index_converter = build_index_converter(
1185            bruker_lib_path,
1186            data_path,
1187            use_bruker_sdk,
1188            scan_max_index,
1189            im_lower,
1190            im_upper,
1191            tof_max_index,
1192            mz_lower,
1193            mz_upper,
1194        );
1195
1196        TimsDataLoader::Lazy(TimsLazyLoder {
1197            raw_data_layout,
1198            index_converter,
1199        })
1200    }
1201
1202    pub fn new_in_memory(
1203        bruker_lib_path: &str,
1204        data_path: &str,
1205        use_bruker_sdk: bool,
1206        scan_max_index: u32,
1207        im_lower: f64,
1208        im_upper: f64,
1209        tof_max_index: u32,
1210        mz_lower: f64,
1211        mz_upper: f64,
1212    ) -> Self {
1213        let raw_data_layout = TimsRawDataLayout::new(data_path);
1214        let index_converter = build_index_converter(
1215            bruker_lib_path,
1216            data_path,
1217            use_bruker_sdk,
1218            scan_max_index,
1219            im_lower,
1220            im_upper,
1221            tof_max_index,
1222            mz_lower,
1223            mz_upper,
1224        );
1225
1226        let mut file_path = PathBuf::from(data_path);
1227        file_path.push("analysis.tdf_bin");
1228        let mut infile = File::open(file_path).unwrap();
1229        let mut data = Vec::new();
1230        infile.read_to_end(&mut data).unwrap();
1231
1232        TimsDataLoader::InMemory(TimsInMemoryLoader {
1233            raw_data_layout,
1234            index_converter,
1235            compressed_data: data,
1236        })
1237    }
1238
1239    /// Create a lazy loader with pre-computed ion mobility calibration lookup table.
1240    ///
1241    /// This method enables accurate ion mobility calibration with fast parallel extraction.
1242    /// The im_lookup table should be pre-computed using the Bruker SDK.
1243    ///
1244    /// # Arguments
1245    /// * `data_path` - Path to the .d folder
1246    /// * `bruker_lib_path` - Path to the Bruker SDK shared library; used to
1247    ///   derive an accurate m/z calibration. Pass "NO_SDK" (or an empty
1248    ///   string) to skip and use the 2-point boundary m/z model.
1249    /// * `tof_max_index` - Maximum TOF index (from GlobalMetaData)
1250    /// * `mz_lower` - Minimum m/z value (from GlobalMetaData)
1251    /// * `mz_upper` - Maximum m/z value (from GlobalMetaData)
1252    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table
1253    ///
1254    /// # Returns
1255    /// A new TimsDataLoader with LookupIndexConverter
1256    pub fn new_lazy_with_calibration(
1257        data_path: &str,
1258        bruker_lib_path: &str,
1259        tof_max_index: u32,
1260        mz_lower: f64,
1261        mz_upper: f64,
1262        im_lookup: Vec<f64>,
1263    ) -> Self {
1264        let raw_data_layout = TimsRawDataLayout::new(data_path);
1265
1266        let index_converter = TimsIndexConverter::Lookup(build_lookup_converter(
1267            bruker_lib_path,
1268            data_path,
1269            tof_max_index,
1270            mz_lower,
1271            mz_upper,
1272            im_lookup,
1273        ));
1274
1275        TimsDataLoader::Lazy(TimsLazyLoder {
1276            raw_data_layout,
1277            index_converter,
1278        })
1279    }
1280
1281    /// Create an in-memory loader with pre-computed ion mobility calibration lookup table.
1282    ///
1283    /// This method enables accurate ion mobility calibration with fast parallel extraction.
1284    /// The im_lookup table should be pre-computed using the Bruker SDK.
1285    ///
1286    /// # Arguments
1287    /// * `data_path` - Path to the .d folder
1288    /// * `bruker_lib_path` - Path to the Bruker SDK shared library; used to
1289    ///   derive an accurate m/z calibration. Pass "NO_SDK" (or an empty
1290    ///   string) to skip and use the 2-point boundary m/z model.
1291    /// * `tof_max_index` - Maximum TOF index (from GlobalMetaData)
1292    /// * `mz_lower` - Minimum m/z value (from GlobalMetaData)
1293    /// * `mz_upper` - Maximum m/z value (from GlobalMetaData)
1294    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table
1295    ///
1296    /// # Returns
1297    /// A new TimsDataLoader with LookupIndexConverter
1298    pub fn new_in_memory_with_calibration(
1299        data_path: &str,
1300        bruker_lib_path: &str,
1301        tof_max_index: u32,
1302        mz_lower: f64,
1303        mz_upper: f64,
1304        im_lookup: Vec<f64>,
1305    ) -> Self {
1306        let raw_data_layout = TimsRawDataLayout::new(data_path);
1307
1308        let index_converter = TimsIndexConverter::Lookup(build_lookup_converter(
1309            bruker_lib_path,
1310            data_path,
1311            tof_max_index,
1312            mz_lower,
1313            mz_upper,
1314            im_lookup,
1315        ));
1316
1317        let mut file_path = PathBuf::from(data_path);
1318        file_path.push("analysis.tdf_bin");
1319        let mut infile = File::open(file_path).unwrap();
1320        let mut data = Vec::new();
1321        infile.read_to_end(&mut data).unwrap();
1322
1323        TimsDataLoader::InMemory(TimsInMemoryLoader {
1324            raw_data_layout,
1325            index_converter,
1326            compressed_data: data,
1327        })
1328    }
1329
1330    /// Create a lazy loader using the exact SDK-free Bruker calibration formulas.
1331    ///
1332    /// Builds a [`BrukerFormulaConverter`] from the `MzCalibration` /
1333    /// `TimsCalibration` tables (frame `calibration_frame_id`, default 1 — the
1334    /// coefficients are near-constant per run). Needs no Bruker SDK at build or
1335    /// runtime; 1/K0 is machine-exact and m/z is bit-exact for MzCalibration
1336    /// ModelType 1 (~1 ppm for ModelType 2).
1337    pub fn new_lazy_with_bruker_formula(data_path: &str, calibration_frame_id: u32) -> Self {
1338        let raw_data_layout = TimsRawDataLayout::new(data_path);
1339        let index_converter = TimsIndexConverter::BrukerFormula(
1340            BrukerFormulaConverter::from_d_folder(data_path, calibration_frame_id).unwrap(),
1341        );
1342        TimsDataLoader::Lazy(TimsLazyLoder {
1343            raw_data_layout,
1344            index_converter,
1345        })
1346    }
1347
1348    /// In-memory counterpart of [`Self::new_lazy_with_bruker_formula`].
1349    pub fn new_in_memory_with_bruker_formula(data_path: &str, calibration_frame_id: u32) -> Self {
1350        let raw_data_layout = TimsRawDataLayout::new(data_path);
1351        let index_converter = TimsIndexConverter::BrukerFormula(
1352            BrukerFormulaConverter::from_d_folder(data_path, calibration_frame_id).unwrap(),
1353        );
1354
1355        let mut file_path = PathBuf::from(data_path);
1356        file_path.push("analysis.tdf_bin");
1357        let mut infile = File::open(file_path).unwrap();
1358        let mut data = Vec::new();
1359        infile.read_to_end(&mut data).unwrap();
1360
1361        TimsDataLoader::InMemory(TimsInMemoryLoader {
1362            raw_data_layout,
1363            index_converter,
1364            compressed_data: data,
1365        })
1366    }
1367
1368    /// Create a lazy 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    ///
1373    /// # Arguments
1374    /// * `data_path` - Path to the .d folder
1375    /// * `tof_intercept` - Intercept for sqrt(mz) = intercept + slope * tof
1376    /// * `tof_slope` - Slope for sqrt(mz) = intercept + slope * tof
1377    /// * `im_min` - Minimum 1/K0 value
1378    /// * `im_max` - Maximum 1/K0 value
1379    /// * `scan_max_index` - Maximum scan index
1380    pub fn new_lazy_with_mz_calibration(
1381        data_path: &str,
1382        tof_intercept: f64,
1383        tof_slope: f64,
1384        im_min: f64,
1385        im_max: f64,
1386        scan_max_index: u32,
1387    ) -> Self {
1388        let raw_data_layout = TimsRawDataLayout::new(data_path);
1389
1390        let index_converter = TimsIndexConverter::Calibrated(CalibratedIndexConverter::new(
1391            tof_intercept,
1392            tof_slope,
1393            im_min,
1394            im_max,
1395            scan_max_index,
1396        ));
1397
1398        TimsDataLoader::Lazy(TimsLazyLoder {
1399            raw_data_layout,
1400            index_converter,
1401        })
1402    }
1403
1404    /// Create an in-memory loader with full calibration (both m/z and IM).
1405    ///
1406    /// This method uses regression-derived m/z calibration coefficients instead of
1407    /// the simple boundary model, providing more accurate m/z conversion.
1408    pub fn new_in_memory_with_mz_calibration(
1409        data_path: &str,
1410        tof_intercept: f64,
1411        tof_slope: f64,
1412        im_min: f64,
1413        im_max: f64,
1414        scan_max_index: u32,
1415    ) -> Self {
1416        let raw_data_layout = TimsRawDataLayout::new(data_path);
1417
1418        let index_converter = TimsIndexConverter::Calibrated(CalibratedIndexConverter::new(
1419            tof_intercept,
1420            tof_slope,
1421            im_min,
1422            im_max,
1423            scan_max_index,
1424        ));
1425
1426        let mut file_path = PathBuf::from(data_path);
1427        file_path.push("analysis.tdf_bin");
1428        let mut infile = File::open(file_path).unwrap();
1429        let mut data = Vec::new();
1430        infile.read_to_end(&mut data).unwrap();
1431
1432        TimsDataLoader::InMemory(TimsInMemoryLoader {
1433            raw_data_layout,
1434            index_converter,
1435            compressed_data: data,
1436        })
1437    }
1438
1439    pub fn get_index_converter(&self) -> &dyn IndexConverter {
1440        match self {
1441            TimsDataLoader::InMemory(loader) => &loader.index_converter,
1442            TimsDataLoader::Lazy(loader) => &loader.index_converter,
1443        }
1444    }
1445
1446    /// `get_frame` with an explicit index converter (see [`TimsLazyLoder::get_frame_with_converter`]).
1447    pub fn get_frame_with_converter(&self, frame_id: u32, conv: &dyn IndexConverter) -> TimsFrame {
1448        match self {
1449            TimsDataLoader::InMemory(loader) => loader.get_frame_with_converter(frame_id, conv),
1450            TimsDataLoader::Lazy(loader) => loader.get_frame_with_converter(frame_id, conv),
1451        }
1452    }
1453
1454    /// Path of the `.d` folder this loader reads from.
1455    pub fn raw_data_path(&self) -> &str {
1456        match self {
1457            TimsDataLoader::InMemory(loader) => &loader.raw_data_layout.raw_data_path,
1458            TimsDataLoader::Lazy(loader) => &loader.raw_data_layout.raw_data_path,
1459        }
1460    }
1461
1462    /// The loader's own index converter, if it is safe to use from several threads — i.e.
1463    /// everything except the Bruker SDK wrapper, whose `tims_index_to_mz` must not be called
1464    /// concurrently on one handle.
1465    pub fn sync_index_converter(&self) -> Option<&(dyn IndexConverter + Sync)> {
1466        let converter = match self {
1467            TimsDataLoader::InMemory(loader) => &loader.index_converter,
1468            TimsDataLoader::Lazy(loader) => &loader.index_converter,
1469        };
1470        match converter {
1471            TimsIndexConverter::BrukerLib(_) => None,
1472            TimsIndexConverter::Simple(c) => Some(c),
1473            TimsIndexConverter::Calibrated(c) => Some(c),
1474            TimsIndexConverter::Lookup(c) => Some(c),
1475            TimsIndexConverter::BrukerFormula(c) => Some(c),
1476        }
1477    }
1478
1479    /// A converter that is safe to use from many threads at once, for readers that want to
1480    /// run in parallel while the dataset itself was opened with the Bruker SDK.
1481    ///
1482    /// Returns `None` when the loader's own converter is already thread-safe (anything but
1483    /// `BrukerLib`), or when the file carries no calibration tables the SDK-free
1484    /// [`BrukerFormulaConverter`] could evaluate — callers must then serialise their reads.
1485    pub fn sdk_free_converter(&self) -> Option<TimsIndexConverter> {
1486        if !self.uses_bruker_sdk() {
1487            return None;
1488        }
1489        BrukerFormulaConverter::from_d_folder(self.raw_data_path(), 1)
1490            .ok()
1491            // Only substitute when the result is indistinguishable from the SDK's; otherwise the
1492            // caller has to stay on the SDK and serialise. See `is_sdk_exact`.
1493            .filter(|c| c.is_sdk_exact())
1494            .map(TimsIndexConverter::BrukerFormula)
1495    }
1496
1497    /// Check if the Bruker SDK is being used for index conversion.
1498    /// The Bruker SDK is NOT thread-safe, so parallel operations that call
1499    /// the index converter must be disabled when using the SDK.
1500    pub fn uses_bruker_sdk(&self) -> bool {
1501        match self {
1502            TimsDataLoader::InMemory(loader) => matches!(&loader.index_converter, TimsIndexConverter::BrukerLib(_)),
1503            TimsDataLoader::Lazy(loader) => matches!(&loader.index_converter, TimsIndexConverter::BrukerLib(_)),
1504        }
1505    }
1506}
1507
1508impl TimsData for TimsDataLoader {
1509    fn get_frame(&self, frame_id: u32) -> TimsFrame {
1510        match self {
1511            TimsDataLoader::InMemory(loader) => loader.get_frame(frame_id),
1512            TimsDataLoader::Lazy(loader) => loader.get_frame(frame_id),
1513        }
1514    }
1515    fn get_raw_frame(&self, frame_id: u32) -> RawTimsFrame {
1516        match self {
1517            TimsDataLoader::InMemory(loader) => loader.get_raw_frame(frame_id),
1518            TimsDataLoader::Lazy(loader) => loader.get_raw_frame(frame_id),
1519        }
1520    }
1521
1522    fn get_slice(&self, frame_ids: Vec<u32>, num_threads: usize) -> TimsSlice {
1523        match self {
1524            TimsDataLoader::InMemory(loader) => loader.get_slice(frame_ids, num_threads),
1525            TimsDataLoader::Lazy(loader) => loader.get_slice(frame_ids, num_threads),
1526        }
1527    }
1528
1529    fn get_acquisition_mode(&self) -> AcquisitionMode {
1530        match self {
1531            TimsDataLoader::InMemory(loader) => loader.get_acquisition_mode(),
1532            TimsDataLoader::Lazy(loader) => loader.get_acquisition_mode(),
1533        }
1534    }
1535
1536    fn get_frame_count(&self) -> i32 {
1537        match self {
1538            TimsDataLoader::InMemory(loader) => loader.get_frame_count(),
1539            TimsDataLoader::Lazy(loader) => loader.get_frame_count(),
1540        }
1541    }
1542
1543    fn get_data_path(&self) -> &str {
1544        match self {
1545            TimsDataLoader::InMemory(loader) => loader.get_data_path(),
1546            TimsDataLoader::Lazy(loader) => loader.get_data_path(),
1547        }
1548    }
1549}
1550
1551pub struct SimpleIndexConverter {
1552    pub tof_intercept: f64,
1553    pub tof_slope: f64,
1554    pub scan_intercept: f64,
1555    pub scan_slope: f64,
1556}
1557
1558impl SimpleIndexConverter {
1559    pub fn from_boundaries(
1560        mz_min: f64,
1561        mz_max: f64,
1562        tof_max_index: u32,
1563        im_min: f64,
1564        im_max: f64,
1565        scan_max_index: u32,
1566    ) -> Self {
1567        let tof_intercept: f64 = mz_min.sqrt();
1568        let tof_slope: f64 = (mz_max.sqrt() - tof_intercept) / tof_max_index as f64;
1569
1570        let scan_intercept: f64 = im_max;
1571        let scan_slope: f64 = (im_min - scan_intercept) / scan_max_index as f64;
1572        Self {
1573            tof_intercept,
1574            tof_slope,
1575            scan_intercept,
1576            scan_slope,
1577        }
1578    }
1579}
1580
1581impl IndexConverter for SimpleIndexConverter {
1582    fn tof_to_mz(&self, _frame_id: u32, _tof_values: &Vec<u32>) -> Vec<f64> {
1583        let mut mz_values: Vec<f64> = Vec::new();
1584        mz_values.resize(_tof_values.len(), 0.0);
1585
1586        for (i, &val) in _tof_values.iter().enumerate() {
1587            mz_values[i] = (self.tof_intercept + self.tof_slope * val as f64).powi(2);
1588        }
1589
1590        mz_values
1591    }
1592
1593    fn mz_to_tof(&self, _frame_id: u32, _mz_values: &Vec<f64>) -> Vec<u32> {
1594        let mut tof_values: Vec<u32> = Vec::new();
1595        tof_values.resize(_mz_values.len(), 0);
1596
1597        for (i, &val) in _mz_values.iter().enumerate() {
1598            tof_values[i] = ((val.sqrt() - self.tof_intercept) / self.tof_slope) as u32;
1599        }
1600
1601        tof_values
1602    }
1603
1604    fn scan_to_inverse_mobility(&self, _frame_id: u32, _scan_values: &Vec<u32>) -> Vec<f64> {
1605        let mut inv_mobility_values: Vec<f64> = Vec::new();
1606        inv_mobility_values.resize(_scan_values.len(), 0.0);
1607
1608        for (i, &val) in _scan_values.iter().enumerate() {
1609            inv_mobility_values[i] = self.scan_intercept + self.scan_slope * val as f64;
1610        }
1611
1612        inv_mobility_values
1613    }
1614
1615    fn inverse_mobility_to_scan(
1616        &self,
1617        _frame_id: u32,
1618        _inverse_mobility_values: &Vec<f64>,
1619    ) -> Vec<u32> {
1620        let mut scan_values: Vec<u32> = Vec::new();
1621        scan_values.resize(_inverse_mobility_values.len(), 0);
1622
1623        for (i, &val) in _inverse_mobility_values.iter().enumerate() {
1624            scan_values[i] = ((val - self.scan_intercept) / self.scan_slope) as u32;
1625        }
1626
1627        scan_values
1628    }
1629}
1630
1631/// M/z calibrated index converter using regression-derived coefficients.
1632///
1633/// This provides accurate TOF to m/z conversion without requiring the Bruker SDK
1634/// by using linear regression coefficients derived from known precursor m/z values.
1635///
1636/// The calibration formula is:
1637///   sqrt(mz) = tof_intercept + tof_slope * tof_index
1638///
1639/// This is similar to SimpleIndexConverter but uses externally-provided coefficients
1640/// (e.g., from regression on precursor data) rather than boundary-derived values.
1641pub struct CalibratedIndexConverter {
1642    pub tof_intercept: f64,
1643    pub tof_slope: f64,
1644    pub scan_intercept: f64,
1645    pub scan_slope: f64,
1646}
1647
1648impl CalibratedIndexConverter {
1649    /// Create a new calibrated converter with regression-derived coefficients.
1650    ///
1651    /// # Arguments
1652    /// * `tof_intercept` - Intercept for sqrt(mz) = intercept + slope * tof
1653    /// * `tof_slope` - Slope for sqrt(mz) = intercept + slope * tof
1654    /// * `im_min` - Minimum 1/K0 value
1655    /// * `im_max` - Maximum 1/K0 value
1656    /// * `scan_max_index` - Maximum scan index
1657    pub fn new(
1658        tof_intercept: f64,
1659        tof_slope: f64,
1660        im_min: f64,
1661        im_max: f64,
1662        scan_max_index: u32,
1663    ) -> Self {
1664        let scan_intercept = im_max;
1665        let scan_slope = (im_min - scan_intercept) / scan_max_index as f64;
1666        Self {
1667            tof_intercept,
1668            tof_slope,
1669            scan_intercept,
1670            scan_slope,
1671        }
1672    }
1673}
1674
1675impl IndexConverter for CalibratedIndexConverter {
1676    fn tof_to_mz(&self, _frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1677        let mut mz_values: Vec<f64> = Vec::with_capacity(tof_values.len());
1678
1679        for &tof_index in tof_values.iter() {
1680            // sqrt(mz) = tof_intercept + tof_slope * tof_index
1681            let sqrt_mz = self.tof_intercept + self.tof_slope * tof_index as f64;
1682            mz_values.push(sqrt_mz * sqrt_mz);
1683        }
1684
1685        mz_values
1686    }
1687
1688    fn mz_to_tof(&self, _frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1689        let mut tof_values: Vec<u32> = Vec::with_capacity(mz_values.len());
1690
1691        for &mz in mz_values.iter() {
1692            let sqrt_mz = mz.sqrt();
1693            // tof_index = (sqrt(mz) - tof_intercept) / tof_slope
1694            tof_values.push(((sqrt_mz - self.tof_intercept) / self.tof_slope) as u32);
1695        }
1696
1697        tof_values
1698    }
1699
1700    fn scan_to_inverse_mobility(&self, _frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1701        let mut inv_mobility_values: Vec<f64> = Vec::with_capacity(scan_values.len());
1702
1703        for &val in scan_values.iter() {
1704            inv_mobility_values.push(self.scan_intercept + self.scan_slope * val as f64);
1705        }
1706
1707        inv_mobility_values
1708    }
1709
1710    fn inverse_mobility_to_scan(
1711        &self,
1712        _frame_id: u32,
1713        inverse_mobility_values: &Vec<f64>,
1714    ) -> Vec<u32> {
1715        let mut scan_values: Vec<u32> = Vec::with_capacity(inverse_mobility_values.len());
1716
1717        for &val in inverse_mobility_values.iter() {
1718            scan_values.push(((val - self.scan_intercept) / self.scan_slope) as u32);
1719        }
1720
1721        scan_values
1722    }
1723}
1724
1725/// Ion mobility index converter using pre-computed lookup table.
1726///
1727/// This converter uses a pre-computed scan→1/K0 lookup table extracted from the Bruker SDK.
1728/// It enables accurate ion mobility calibration with fast parallel extraction.
1729///
1730/// Background:
1731/// - The Bruker calibration formula is patented and proprietary
1732/// - Using the Bruker SDK gives accurate values but is slow (not thread-safe)
1733/// - Linear interpolation is fast but inaccurate
1734/// - This converter uses SDK-probed lookup for accuracy with O(1) thread-safe lookups
1735///
1736/// The lookup table is typically small (~8KB for 1000 scans) and constant across all frames.
1737pub struct LookupIndexConverter {
1738    // m/z conversion uses simple linear model (accurate enough for most purposes)
1739    pub tof_intercept: f64,
1740    pub tof_slope: f64,
1741
1742    // Ion mobility: pre-computed lookup table from Bruker SDK
1743    // scan_index → 1/K0 value
1744    pub im_lookup: Vec<f64>,
1745
1746    // Fallback for inverse conversion (1/K0 → scan)
1747    // We store the min/max for binary search bounds
1748    pub im_min: f64,
1749    pub im_max: f64,
1750}
1751
1752impl LookupIndexConverter {
1753    /// Create a new LookupIndexConverter with pre-computed ion mobility lookup.
1754    ///
1755    /// # Arguments
1756    /// * `mz_min` - Minimum m/z value for TOF conversion
1757    /// * `mz_max` - Maximum m/z value for TOF conversion
1758    /// * `tof_max_index` - Maximum TOF index
1759    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table from Bruker SDK
1760    ///
1761    /// # Returns
1762    /// A new LookupIndexConverter instance
1763    pub fn new(
1764        mz_min: f64,
1765        mz_max: f64,
1766        tof_max_index: u32,
1767        im_lookup: Vec<f64>,
1768    ) -> Self {
1769        let tof_intercept: f64 = mz_min.sqrt();
1770        let tof_slope: f64 = (mz_max.sqrt() - tof_intercept) / tof_max_index as f64;
1771
1772        // Get IM bounds for inverse conversion
1773        let im_min = im_lookup.iter().cloned().fold(f64::INFINITY, f64::min);
1774        let im_max = im_lookup.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1775
1776        Self {
1777            tof_intercept,
1778            tof_slope,
1779            im_lookup,
1780            im_min,
1781            im_max,
1782        }
1783    }
1784
1785    /// Create a `LookupIndexConverter` with regression-derived m/z coefficients.
1786    ///
1787    /// Uses `sqrt(mz) = tof_intercept + tof_slope * tof` with coefficients fitted
1788    /// from the Bruker SDK (see `derive_mz_calibration`), instead of the 2-point
1789    /// boundary model in `new`. Preferred whenever the SDK is available.
1790    ///
1791    /// # Arguments
1792    /// * `tof_intercept` - Intercept of the sqrt(mz)-vs-tof regression
1793    /// * `tof_slope` - Slope of the sqrt(mz)-vs-tof regression
1794    /// * `im_lookup` - Pre-computed scan→1/K0 lookup table from Bruker SDK
1795    pub fn with_mz_fit(tof_intercept: f64, tof_slope: f64, im_lookup: Vec<f64>) -> Self {
1796        // Get IM bounds for inverse conversion
1797        let im_min = im_lookup.iter().cloned().fold(f64::INFINITY, f64::min);
1798        let im_max = im_lookup.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1799
1800        Self {
1801            tof_intercept,
1802            tof_slope,
1803            im_lookup,
1804            im_min,
1805            im_max,
1806        }
1807    }
1808}
1809
1810impl IndexConverter for LookupIndexConverter {
1811    fn tof_to_mz(&self, _frame_id: u32, tof_values: &Vec<u32>) -> Vec<f64> {
1812        let mut mz_values: Vec<f64> = Vec::new();
1813        mz_values.resize(tof_values.len(), 0.0);
1814
1815        for (i, &val) in tof_values.iter().enumerate() {
1816            mz_values[i] = (self.tof_intercept + self.tof_slope * val as f64).powi(2);
1817        }
1818
1819        mz_values
1820    }
1821
1822    fn mz_to_tof(&self, _frame_id: u32, mz_values: &Vec<f64>) -> Vec<u32> {
1823        let mut tof_values: Vec<u32> = Vec::new();
1824        tof_values.resize(mz_values.len(), 0);
1825
1826        for (i, &val) in mz_values.iter().enumerate() {
1827            tof_values[i] = ((val.sqrt() - self.tof_intercept) / self.tof_slope) as u32;
1828        }
1829
1830        tof_values
1831    }
1832
1833    fn scan_to_inverse_mobility(&self, _frame_id: u32, scan_values: &Vec<u32>) -> Vec<f64> {
1834        // Use the pre-computed lookup table for O(1) conversion
1835        scan_values
1836            .iter()
1837            .map(|&s| {
1838                self.im_lookup
1839                    .get(s as usize)
1840                    .copied()
1841                    .unwrap_or(f64::NAN)
1842            })
1843            .collect()
1844    }
1845
1846    fn inverse_mobility_to_scan(
1847        &self,
1848        _frame_id: u32,
1849        inverse_mobility_values: &Vec<f64>,
1850    ) -> Vec<u32> {
1851        // Use binary search to find the closest scan index for each 1/K0 value
1852        // The lookup table is monotonically decreasing (higher scan = lower 1/K0)
1853        inverse_mobility_values
1854            .iter()
1855            .map(|&im| {
1856                if im.is_nan() || self.im_lookup.is_empty() {
1857                    return 0;
1858                }
1859
1860                // Binary search for the closest value
1861                // Note: im_lookup is typically monotonically decreasing
1862                let mut best_scan = 0usize;
1863                let mut best_diff = f64::INFINITY;
1864
1865                for (scan, &lookup_im) in self.im_lookup.iter().enumerate() {
1866                    let diff = (lookup_im - im).abs();
1867                    if diff < best_diff {
1868                        best_diff = diff;
1869                        best_scan = scan;
1870                    }
1871                }
1872
1873                best_scan as u32
1874            })
1875            .collect()
1876    }
1877}