Skip to main content

koth_ms/lfq/
grid.rs

1use crate::models::Hill;
2use std::collections::HashSet;
3
4use super::LfqConfig;
5
6#[cfg(test)]
7#[path = "grid_tests.rs"]
8mod tests;
9
10/// Original sample identity, independent of grid coordinates and isotope row.
11pub type SampleId = (u32, usize);
12
13#[derive(Debug, Clone)]
14pub struct GridSample {
15    pub id: SampleId,
16    pub row: usize,
17    pub col: usize,
18}
19
20pub const C13_NEUTRON: f64 = 1.003_354_835;
21
22/// Cache-friendly, mz-sorted lookup record for `build_grid`.
23///
24/// Holds only the fields the hot loop reads (mz / rt / rt_start / rt_end / im)
25/// plus an index back into `run.hills` for the eventual `fill_row_from_hill`
26/// call. Packed to 48 bytes so each candidate access during the in-band scan
27/// is a single cache-line fetch — versus the 200-byte `Hill` struct, where
28/// the same five fields are scattered across multiple cache lines and 75% of
29/// each loaded line is unused.
30#[derive(Clone, Copy)]
31#[repr(C)]
32pub struct HillKey {
33    pub mz: f64,
34    pub rt: f64,
35    pub rt_start: f64,
36    pub rt_end: f64,
37    pub im: f64,
38    /// Index back into `run.hills` — only used for `fill_row_from_hill`.
39    pub hill_idx: u32,
40    _pad: u32,
41}
42
43/// `Vec<HillKey>` sorted ascending by mz, plus a stable name for the SoA-style
44/// callsite signature.
45pub struct SortedHills {
46    pub keys: Vec<HillKey>,
47}
48
49impl SortedHills {
50    /// Build the sorted view from a run's hills. Records are sorted ascending by mz.
51    pub fn from_hills(hills: &[Hill]) -> Self {
52        let mut keys: Vec<HillKey> = hills
53            .iter()
54            .enumerate()
55            .map(|(i, h)| HillKey {
56                mz: h.mz,
57                rt: h.rt,
58                rt_start: h.rt_start,
59                rt_end: h.rt_end,
60                im: h.im,
61                hill_idx: i as u32,
62                _pad: 0,
63            })
64            .collect();
65        keys.sort_by(|a, b| a.mz.partial_cmp(&b.mz).unwrap_or(std::cmp::Ordering::Equal));
66        SortedHills { keys }
67    }
68
69    pub fn len(&self) -> usize {
70        self.keys.len()
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.keys.is_empty()
75    }
76}
77
78/// An extracted-ion-chromatogram grid for a single feature in a single run.
79///
80/// Layout: `intensities[isotopologue][rt_bin]`
81/// - Rows 0..n_isotopes correspond to M, M+1, M+2, ...
82/// - Columns 0..grid_cols span [rt_min, rt_max] in equal-width bins.
83#[derive(Debug, Clone)]
84pub struct XicGrid {
85    /// Positive original samples admitted to this grid (for exclusive extraction).
86    pub samples: Vec<GridSample>,
87    /// `[isotopologue][rt_bin]` intensities (summed from matching hills)
88    pub intensities: Vec<Vec<f32>>,
89    pub rt_min: f64,
90    pub rt_max: f64,
91    /// How many of the n_isotopes rows have any non-zero signal
92    pub n_slots_filled: u8,
93    /// Centroid m/z of the winning hill per isotopologue row.
94    /// NaN if no hill matched that row. Length = n_rows.
95    pub winner_mz: Vec<f64>,
96    /// Ion mobility of the winning hill per isotopologue row.
97    /// NaN if no hill matched. Length = n_rows.
98    pub winner_im: Vec<f64>,
99}
100
101impl XicGrid {
102    pub fn empty(n_rows: usize, n_cols: usize, rt_min: f64, rt_max: f64) -> Self {
103        Self {
104            samples: Vec::new(),
105            intensities: vec![vec![0.0f32; n_cols]; n_rows],
106            rt_min,
107            rt_max,
108            n_slots_filled: 0,
109            winner_mz: vec![f64::NAN; n_rows],
110            winner_im: vec![f64::NAN; n_rows],
111        }
112    }
113
114    /// Zero all intensity rows and update RT bounds for reuse across (feature, run) pairs.
115    pub fn reset(&mut self, rt_min: f64, rt_max: f64) {
116        for row in &mut self.intensities {
117            row.fill(0.0);
118        }
119        self.samples.clear();
120        self.rt_min = rt_min;
121        self.rt_max = rt_max;
122        self.n_slots_filled = 0;
123        for v in &mut self.winner_mz {
124            *v = f64::NAN;
125        }
126        for v in &mut self.winner_im {
127            *v = f64::NAN;
128        }
129    }
130
131    pub fn n_rows(&self) -> usize {
132        self.intensities.len()
133    }
134
135    pub fn n_cols(&self) -> usize {
136        self.intensities.first().map_or(0, |r| r.len())
137    }
138
139    pub fn is_empty(&self) -> bool {
140        self.n_slots_filled == 0
141    }
142}
143
144/// Build an XIC grid for a single feature in a single run.
145///
146/// `grid` is reset (zeroed) at entry and filled in-place; callers should
147/// pre-allocate it once and pass the same instance across (feature, run) pairs.
148/// `target_mz` / `target_rt` / `target_im` are already expressed in the
149/// reference coordinate space (alignment-corrected).  The function searches
150/// `hills` (a mz-sorted `SortedHills` SoA view of the run) is binary-searched
151/// for hills matching each isotopologue; the winner is then materialised
152/// from `hills_data` (the run's full `Vec<Hill>`) to read its `intensity_profile`.
153pub fn build_grid(
154    grid: &mut XicGrid,
155    hills_data: &[Hill],
156    hills: &SortedHills,
157    scan_times: &[f64],
158    target_mz: f64,
159    charge: u8,
160    target_rt: f64,
161    target_im: f64,
162    half_window: f64,
163    config: &LfqConfig,
164) {
165    build_grid_excluding(
166        grid,
167        hills_data,
168        hills,
169        scan_times,
170        target_mz,
171        charge,
172        target_rt,
173        target_im,
174        half_window,
175        config,
176        &HashSet::new(),
177    );
178}
179
180/// Same extraction for targets, controls and residual candidates. Raw hills are
181/// immutable; exclusions use original profile indices, never shifted grid bins.
182pub fn build_grid_excluding(
183    grid: &mut XicGrid,
184    hills_data: &[Hill],
185    hills: &SortedHills,
186    scan_times: &[f64],
187    target_mz: f64,
188    charge: u8,
189    target_rt: f64,
190    target_im: f64,
191    half_window: f64,
192    config: &LfqConfig,
193    excluded: &HashSet<SampleId>,
194) {
195    let n_rows = config.n_isotopes;
196    let n_cols = config.grid_cols;
197
198    let rt_min = target_rt - half_window;
199    let rt_max = target_rt + half_window;
200
201    grid.reset(rt_min, rt_max);
202    let has_im = target_im != 0.0;
203
204    for iso in 0..n_rows {
205        let expected_mz = target_mz + iso as f64 * C13_NEUTRON / charge as f64;
206        let mz_tol = expected_mz * config.mz_ppm / 1e6;
207        let mz_lo = expected_mz - mz_tol;
208        let mz_hi = expected_mz + mz_tol;
209
210        // Binary search over the packed mz field (stride-48 reads, but the
211        // candidate slice below is contiguous).
212        let lo = hills.keys.partition_point(|k| k.mz < mz_lo);
213        let hi = hills.keys.partition_point(|k| k.mz <= mz_hi);
214
215        // Sum *every* in-box hill into this isotope row. Each hill emits
216        // per-scan intensities (its `intensity_profile`) which are binned
217        // into the grid's RT columns; calling `fill_row_from_hill` more
218        // than once accumulates because the inner loop does `row[col] +=`.
219        // The net effect is a reconstruction of the raw MS1 XIC at this
220        // m/z across the grid's RT window, built from the hill database
221        // without going back to the mzML.
222        //
223        // The "winner" hill (highest `intensity_sum` among contributors)
224        // is tracked only for the diagnostic `observed_mz` / `observed_im`
225        // fields written into LfqEntry — it does not change which signal
226        // is integrated.
227        let mut any_added = false;
228        let mut winner_intensity: f32 = 0.0;
229        for k_idx in lo..hi {
230            let k = &hills.keys[k_idx];
231            if k.rt_end < rt_min || k.rt_start > rt_max {
232                continue;
233            }
234            if has_im && k.im != 0.0 && (k.im - target_im).abs() > config.im_tolerance {
235                continue;
236            }
237            let hill = &hills_data[k.hill_idx as usize];
238            let mut added = false;
239            let span = rt_max - rt_min;
240            if span <= 0.0 || n_cols == 0 {
241                continue;
242            }
243            for (i, &intensity) in hill.intensity_profile.iter().enumerate() {
244                if intensity <= 0.0 || !intensity.is_finite() || excluded.contains(&(k.hill_idx, i))
245                {
246                    continue;
247                }
248                let t = (scan_rt(hill, i, scan_times) - rt_min) / span;
249                if !(0.0..1.0).contains(&t) {
250                    continue;
251                }
252                let col = ((t * n_cols as f64) as usize).min(n_cols - 1);
253                grid.intensities[iso][col] += intensity;
254                grid.samples.push(GridSample {
255                    id: (k.hill_idx, i),
256                    row: iso,
257                    col,
258                });
259                added = true;
260            }
261            if added {
262                any_added = true;
263                let intensity_sum = hill.intensity_sum as f32;
264                if intensity_sum > winner_intensity {
265                    winner_intensity = intensity_sum;
266                    grid.winner_mz[iso] = hill.mz;
267                    grid.winner_im[iso] = hill.im;
268                }
269            }
270        }
271        if any_added {
272            grid.n_slots_filled += 1;
273        }
274    }
275}
276
277/// Splat a hill's per-scan intensities into a single grid row.
278/// Returns true if at least one sample fell within the window.
279#[cfg(test)]
280fn fill_row_from_hill(
281    hill: &Hill,
282    row: &mut [f32],
283    rt_min: f64,
284    rt_max: f64,
285    n_cols: usize,
286    scan_times: &[f64],
287) -> bool {
288    let rt_span = rt_max - rt_min;
289    if rt_span <= 0.0 || hill.intensity_profile.is_empty() {
290        return false;
291    }
292
293    let mut any = false;
294
295    for (i, &intensity) in hill.intensity_profile.iter().enumerate() {
296        if intensity <= 0.0 {
297            continue;
298        }
299
300        let rt = scan_rt(hill, i, scan_times);
301        let t = (rt - rt_min) / rt_span;
302        if !(0.0..1.0).contains(&t) {
303            continue;
304        }
305
306        let col = (t * n_cols as f64) as usize;
307        let col = col.min(n_cols - 1);
308        row[col] += intensity;
309        any = true;
310    }
311
312    any
313}
314
315/// Return the retention time for the i-th sample of a hill's intensity profile.
316/// Uses the run's scan_times array when available; falls back to linear
317/// interpolation between hill.rt_start and hill.rt_end otherwise.
318fn scan_rt(hill: &Hill, profile_index: usize, scan_times: &[f64]) -> f64 {
319    if !scan_times.is_empty() {
320        let scan_abs = hill.scan_start + profile_index;
321        if scan_abs < scan_times.len() {
322            return scan_times[scan_abs];
323        }
324    }
325    // Linear interpolation fallback
326    let n = hill.intensity_profile.len();
327    if n <= 1 {
328        return hill.rt;
329    }
330    hill.rt_start + (hill.rt_end - hill.rt_start) * profile_index as f64 / (n - 1) as f64
331}