1use crate::models::Hill;
2use std::collections::HashSet;
3
4use super::LfqConfig;
5
6#[cfg(test)]
7#[path = "grid_tests.rs"]
8mod tests;
9
10pub 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#[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 pub hill_idx: u32,
40 _pad: u32,
41}
42
43pub struct SortedHills {
46 pub keys: Vec<HillKey>,
47}
48
49impl SortedHills {
50 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#[derive(Debug, Clone)]
84pub struct XicGrid {
85 pub samples: Vec<GridSample>,
87 pub intensities: Vec<Vec<f32>>,
89 pub rt_min: f64,
90 pub rt_max: f64,
91 pub n_slots_filled: u8,
93 pub winner_mz: Vec<f64>,
96 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 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
144pub 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
180pub 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 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 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#[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
315fn 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 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}