Skip to main content

rustdf/sim/
precursor.rs

1use mscore::simulation::noise_rng::noise_rng;
2use mscore::data::peptide::PeptideIon;
3use mscore::data::spectrum::{IndexedMzSpectrum, MsType, MzSpectrum};
4use mscore::simulation::annotation::{
5    MzSpectrumAnnotated, TimsFrameAnnotated, TimsSpectrumAnnotated,
6};
7use mscore::timstof::frame::TimsFrame;
8use mscore::timstof::spectrum::TimsSpectrum;
9use rusqlite::Result;
10use std::collections::{BTreeMap, HashSet};
11use std::path::Path;
12
13use crate::sim::containers::{FramesSim, IonSim, PeptidesSim, ScansSim};
14use crate::sim::handle::TimsTofSyntheticsDataHandle;
15use crate::sim::projector::{IntensityStage, MzCoordSpace, RenderedEvent, RenderedSpectrum};
16use crate::sim::scheme::DataMode;
17use rayon::prelude::*;
18
19pub struct TimsTofSyntheticsPrecursorFrameBuilder {
20    pub ions: BTreeMap<u32, Vec<IonSim>>,
21    pub peptides: BTreeMap<u32, PeptidesSim>,
22    pub scans: Vec<ScansSim>,
23    pub frames: Vec<FramesSim>,
24    pub precursor_frame_id_set: HashSet<u32>,
25    pub frame_to_abundances: BTreeMap<u32, (Vec<u32>, Vec<f32>)>,
26    pub peptide_to_ions: BTreeMap<
27        u32,
28        (
29            Vec<f32>,
30            Vec<Vec<u32>>,
31            Vec<Vec<f32>>,
32            Vec<i8>,
33            Vec<MzSpectrum>,
34        ),
35    >,
36    pub frame_to_rt: BTreeMap<u32, f32>,
37    pub scan_to_mobility: BTreeMap<u32, f32>,
38    pub peptide_to_events: BTreeMap<u32, f32>,
39    /// Mapping from ion_id to (peptide_id, charge) for DDA precursor lookup
40    pub ion_id_to_peptide_charge: BTreeMap<u32, (u32, i8)>,
41    /// Master seed for the simulation's noise. 0 keeps the legacy `thread_rng` behaviour
42    /// for API users that never set it; TimSim sets it from the run's `sample_seed`.
43    pub noise_seed: u64,
44}
45
46impl TimsTofSyntheticsPrecursorFrameBuilder {
47
48    /// Set the master seed for the m/z jitter, making a run's noise reproducible.
49    pub fn set_noise_seed(&mut self, seed: u64) {
50        self.noise_seed = seed;
51    }
52    /// Create a new instance of TimsTofSynthetics
53    ///
54    /// # Arguments
55    ///
56    /// * `path` - A reference to a Path
57    ///
58    /// # Returns
59    ///
60    /// * A Result containing the TimsTofSynthetics instance
61    ///
62    pub fn new(path: &Path) -> Result<Self> {
63        Self::from_source(path, &crate::sim::projector::DistributionSource::Columns)
64    }
65
66    /// Construct the builder with occurrence/abundance distributions sourced from
67    /// `source` (P4): `Columns` (legacy JSON, the default — byte-unchanged) or the
68    /// render-time `Projector`. Identical-shape `PeptidesSim`/`IonSim` either way,
69    /// so the maps + all downstream consumers are untouched.
70    pub fn from_source(
71        path: &Path,
72        source: &crate::sim::projector::DistributionSource,
73    ) -> Result<Self> {
74        let handle = TimsTofSyntheticsDataHandle::new(path)?;
75        let ions = handle.read_ions_with_source(source)?;
76        let peptides = handle.read_peptides_with_source(source)?;
77        let scans = handle.read_scans()?;
78        let frames = handle.read_frames()?;
79
80        Ok(Self::from_entities(ions, peptides, scans, frames))
81    }
82
83    /// Construct the builder from already-loaded, in-memory entities instead of
84    /// reading the database. Used by the lazy builders, which load only a
85    /// per-batch slice of peptides/ions (the `scans`/`frames` metadata is full).
86    /// All lookup maps are derived identically to `from_source`, so a builder
87    /// constructed here behaves exactly like an eager one restricted to the
88    /// supplied entities.
89    pub fn from_entities(
90        ions: Vec<IonSim>,
91        peptides: Vec<PeptidesSim>,
92        scans: Vec<ScansSim>,
93        frames: Vec<FramesSim>,
94    ) -> Self {
95        // Build ion_id to (peptide_id, charge) mapping for DDA precursor lookup
96        let mut ion_id_to_peptide_charge: BTreeMap<u32, (u32, i8)> = BTreeMap::new();
97        for ion in &ions {
98            ion_id_to_peptide_charge.insert(ion.ion_id, (ion.peptide_id, ion.charge));
99        }
100
101        Self {
102            noise_seed: 0,
103            ions: TimsTofSyntheticsDataHandle::build_peptide_to_ion_map(&ions),
104            peptides: TimsTofSyntheticsDataHandle::build_peptide_map(&peptides),
105            scans: scans.clone(),
106            frames: frames.clone(),
107            precursor_frame_id_set: TimsTofSyntheticsDataHandle::build_precursor_frame_id_set(
108                &frames,
109            ),
110            frame_to_abundances: TimsTofSyntheticsDataHandle::build_frame_to_abundances(&peptides),
111            peptide_to_ions: TimsTofSyntheticsDataHandle::build_peptide_to_ions(&ions),
112            frame_to_rt: TimsTofSyntheticsDataHandle::build_frame_to_rt(&frames),
113            scan_to_mobility: TimsTofSyntheticsDataHandle::build_scan_to_mobility(&scans),
114            peptide_to_events: TimsTofSyntheticsDataHandle::build_peptide_to_events(&peptides),
115            ion_id_to_peptide_charge,
116        }
117    }
118
119    /// Vendor-neutral MS1 spectral-contribution kernel (P6a): the ordered
120    /// `(scan, scaled_spectrum)` contributions for a precursor frame — for each
121    /// (peptide, ion, scan) the isotope spectrum scaled by its abundance factor,
122    /// PRE m/z-noise, in builder order (peptide → ion → scan). Bruker aggregates
123    /// these per scan into a `TimsFrame` (see `build_precursor_frame`); a non-IMS
124    /// instrument sums them into a single MS1 `Scan`. Factoring this out lets both
125    /// vendors share the exact same physics without duplicating it.
126    pub fn precursor_frame_contributions(&self, frame_id: u32) -> Vec<(i32, MzSpectrum)> {
127        let mut out: Vec<(i32, MzSpectrum)> = Vec::new();
128        let Some((peptide_ids, abundances)) = self.frame_to_abundances.get(&frame_id) else {
129            return out;
130        };
131        out.reserve(peptide_ids.len() * 4);
132        for (peptide_id, abundance) in peptide_ids.iter().zip(abundances.iter()) {
133            let Some((ion_abundances, scan_occurrences, scan_abundances, _, spectra)) =
134                self.peptide_to_ions.get(peptide_id)
135            else {
136                continue;
137            };
138            let total_events = *self.peptide_to_events.get(peptide_id).unwrap();
139            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
140                let scan_occurrence = &scan_occurrences[index];
141                let scan_abundance = &scan_abundances[index];
142                let spectrum = &spectra[index];
143                for (scan, scan_abu) in scan_occurrence.iter().zip(scan_abundance.iter()) {
144                    let abundance_factor = abundance * ion_abundance * scan_abu * total_events;
145                    out.push((*scan as i32, spectrum.clone() * abundance_factor as f64));
146                }
147            }
148        }
149        out
150    }
151
152    /// Faithful sum of the per-scan contributions for a frame into ONE spectrum:
153    /// `Σ_scans (scaled_spectrum)`. The scan coordinate is discarded; peaks at the
154    /// same m/z merge (`from_collection`'s deterministic m/z binning).
155    ///
156    /// NOTE — this is **not** the correct non-IMS MS1 physics. It reproduces what
157    /// the TIMS scan grid captured, whose total mobility mass is `Σ scan_abundance`
158    /// ≲ `target_p` (grid truncation) and can be far less for ions whose mobility
159    /// sits at/outside the grid edge (those are under-counted, or dropped entirely
160    /// when no scan captured them). A real no-mobility instrument has no such grid,
161    /// so it sees the FULL mobility marginal — use
162    /// [`Self::precursor_scan_marginal_spectrum`] for the Astral render. This
163    /// captured-grid collapse is kept as a diagnostic / exact-equivalent of the
164    /// Bruker per-scan contributions.
165    pub fn precursor_scan_spectrum(&self, frame_id: u32) -> MzSpectrum {
166        let specs: Vec<MzSpectrum> = self
167            .precursor_frame_contributions(frame_id)
168            .into_iter()
169            .map(|(_scan, spectrum)| spectrum)
170            .collect();
171        MzSpectrum::from_collection(specs)
172    }
173
174    /// Non-IMS (e.g. Astral) MS1 spectrum: the **full mobility marginal** (P6c).
175    ///
176    /// A no-mobility instrument integrates the entire ion-mobility distribution
177    /// (marginal = 1.0 by construction) — it cannot lose ions to TIMS-grid
178    /// truncation. So each (peptide, ion) in the frame contributes its isotope
179    /// spectrum scaled by `frame_abundance × ion_abundance × total_events`, with
180    /// the per-scan mobility split (`scan_abundance`, summing to ≲ `target_p`)
181    /// replaced by the full marginal 1.0. Crucially, ions are included by frame
182    /// membership (`frame_abundance > 0`), NOT by whether the TIMS grid captured
183    /// any of their mobility — so grid-edge ions [`precursor_scan_spectrum`] would
184    /// under-count or drop are recorded at full abundance. PRE m/z-noise; peaks at
185    /// the same m/z merge deterministically.
186    pub fn precursor_scan_marginal_spectrum(&self, frame_id: u32) -> MzSpectrum {
187        let mut specs: Vec<MzSpectrum> = Vec::new();
188        let Some((peptide_ids, abundances)) = self.frame_to_abundances.get(&frame_id) else {
189            return MzSpectrum::from_collection(specs);
190        };
191        specs.reserve(peptide_ids.len() * 4);
192        for (peptide_id, frame_abundance) in peptide_ids.iter().zip(abundances.iter()) {
193            let Some((ion_abundances, _scan_occurrences, _scan_abundances, _, spectra)) =
194                self.peptide_to_ions.get(peptide_id)
195            else {
196                continue;
197            };
198            let total_events = *self.peptide_to_events.get(peptide_id).unwrap();
199            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
200                // Full mobility marginal: scan factor folded to 1.0 (no grid).
201                let abundance_factor = frame_abundance * ion_abundance * total_events;
202                specs.push(spectra[index].clone() * abundance_factor as f64);
203            }
204        }
205        MzSpectrum::from_collection(specs)
206    }
207
208    /// Render a precursor (MS1) frame as a vendor-neutral [`RenderedEvent::Scan`]
209    /// for a non-IMS instrument (P6c). This is the scan-based render core's MS1
210    /// path: it collapses the mobility axis via
211    /// [`Self::precursor_scan_marginal_spectrum`] (the full mobility marginal, the
212    /// correct non-IMS physics — NOT the captured-grid sum) into the single MS1
213    /// spectrum an Astral/Orbitrap records.
214    ///
215    /// The spectrum is tagged as physical-m/z, NOT detector-applied, at the
216    /// [`IntensityStage::Mobility`] stage — the marginal already carries the yield
217    /// (events), time (frame abundance) and mobility (fully integrated) factors,
218    /// but no quadrupole transmission (MS1 is unisolated) and no detector response;
219    /// the writer / detector model applies those downstream without double-
220    /// counting. `data_mode` is the instrument's MS1 acquisition mode (Astral MS1 =
221    /// `Profile`). Returns an empty-spectrum `Scan` for a frame with no
222    /// contributions (the caller decides whether to emit it).
223    pub fn render_precursor_scan(&self, frame_id: u32, data_mode: DataMode) -> RenderedEvent {
224        let rt = *self.frame_to_rt.get(&frame_id).unwrap() as f64;
225        let spectrum = self.precursor_scan_marginal_spectrum(frame_id);
226        RenderedEvent::Scan {
227            ms_level: 1,
228            retention_time_s: rt,
229            isolation: None,
230            spectrum: RenderedSpectrum {
231                mz: (*spectrum.mz).clone(),
232                intensity: (*spectrum.intensity).clone(),
233                coords: MzCoordSpace::Physical,
234                mode: data_mode,
235                detector_applied: false,
236                stage: IntensityStage::Mobility,
237            },
238        }
239    }
240
241    /// Build a precursor frame
242    ///
243    /// # Arguments
244    ///
245    /// * `frame_id` - A u32 representing the frame id
246    ///
247    /// # Returns
248    ///
249    /// * A TimsFrame instance
250    pub fn build_precursor_frame(
251        &self,
252        frame_id: u32,
253        mz_noise_precursor: bool,
254        uniform: bool,
255        precursor_noise_ppm: f64,
256        right_drag: bool,
257    ) -> TimsFrame {
258        // One RNG per frame, keyed by the master seed and the frame id, so the m/z jitter
259        // does not depend on which thread builds the frame. See mscore::simulation::noise_rng.
260        let mut rng = noise_rng(self.noise_seed, &[frame_id as u64]);
261        // Cache frame-level lookups
262        let ms_type = if self.precursor_frame_id_set.contains(&frame_id) {
263            MsType::Precursor
264        } else {
265            MsType::Unknown
266        };
267        let rt = *self.frame_to_rt.get(&frame_id).unwrap() as f64;
268
269        // Bruker adapter over the vendor-neutral contribution kernel (P6a):
270        // aggregate the ordered (scan, scaled_spectrum) contributions per scan
271        // into a TimsFrame. Behaviour is identical to the previous inline loop —
272        // same order, same per-contribution m/z-noise, same TimsSpectrum build.
273        let contributions = self.precursor_frame_contributions(frame_id);
274        let mut tims_spectra: Vec<TimsSpectrum> = Vec::with_capacity(contributions.len());
275        for (scan, scaled_spec) in contributions {
276            let mz_spectrum = if mz_noise_precursor {
277                if uniform {
278                    scaled_spec.add_mz_noise_uniform_with_rng(precursor_noise_ppm, right_drag, &mut rng)
279                } else {
280                    scaled_spec.add_mz_noise_normal_with_rng(precursor_noise_ppm, &mut rng)
281                }
282            } else {
283                scaled_spec
284            };
285
286            let scan_mobility = *self.scan_to_mobility.get(&(scan as u32)).unwrap() as f64;
287            let spectrum_len = mz_spectrum.mz.len();
288
289            tims_spectra.push(TimsSpectrum::new(
290                frame_id as i32,
291                scan,
292                rt,
293                scan_mobility,
294                ms_type.clone(),
295                IndexedMzSpectrum::from_mz_spectrum(vec![0; spectrum_len], mz_spectrum),
296            ));
297        }
298
299        // A precursor frame can have peptides assigned (passed the
300        // frame_to_abundances guard above) yet produce NO spectra once every ion
301        // is filtered out. `TimsFrame::from_tims_spectra([])` would then fall back
302        // to frame_id=1 / MsType::Unknown / rt=0.0, emitting a ghost frame that
303        // collides with the real frame 1 (the writer's Frames.Id uniqueness guard
304        // rejects it). Preserve this frame's own id + ms_type for the empty case,
305        // mirroring build_fragment_frame.
306        if tims_spectra.is_empty() {
307            return TimsFrame::new(frame_id as i32, ms_type, rt, vec![], vec![], vec![], vec![], vec![]);
308        }
309        let tims_frame = TimsFrame::from_tims_spectra(tims_spectra);
310        tims_frame.filter_ranged(0.0, 10000.0, 0, 2000, 0.0, 10.0, 1.0, 1e9, 0, i32::MAX)
311    }
312
313    /// Build a collection of precursor frames in parallel
314    ///
315    /// # Arguments
316    ///
317    /// * `frame_ids` - A vector of u32 representing the frame ids
318    /// * `num_threads` - A usize representing the number of threads
319    ///
320    /// # Returns
321    ///
322    /// * A vector of TimsFrame instances
323    ///
324    pub fn build_precursor_frames(
325        &self,
326        frame_ids: Vec<u32>,
327        mz_noise_precursor: bool,
328        uniform: bool,
329        precursor_noise_ppm: f64,
330        right_drag: bool,
331        num_threads: usize,
332    ) -> Vec<TimsFrame> {
333        let pool = rayon::ThreadPoolBuilder::new()
334            .num_threads(num_threads)
335            .build()
336            .unwrap();
337
338        pool.install(|| {
339            // Use indexed parallel iteration to maintain order, avoiding post-sort
340            let mut tims_frames: Vec<TimsFrame> = Vec::with_capacity(frame_ids.len());
341            unsafe { tims_frames.set_len(frame_ids.len()); }
342
343            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
344                let frame = self.build_precursor_frame(
345                    *frame_id,
346                    mz_noise_precursor,
347                    uniform,
348                    precursor_noise_ppm,
349                    right_drag,
350                );
351                unsafe {
352                    let ptr = tims_frames.as_ptr() as *mut TimsFrame;
353                    std::ptr::write(ptr.add(idx), frame);
354                }
355            });
356
357            tims_frames
358        })
359    }
360
361    pub fn build_precursor_frame_annotated(
362        &self,
363        frame_id: u32,
364        mz_noise_precursor: bool,
365        uniform: bool,
366        precursor_noise_ppm: f64,
367        right_drag: bool,
368    ) -> TimsFrameAnnotated {
369        // One RNG per frame, keyed by the master seed and the frame id, so the m/z jitter
370        // does not depend on which thread builds the frame. See mscore::simulation::noise_rng.
371        let mut rng = noise_rng(self.noise_seed, &[frame_id as u64]);
372        // Cache frame-level lookups
373        let ms_type = if self.precursor_frame_id_set.contains(&frame_id) {
374            MsType::Precursor
375        } else {
376            MsType::Unknown
377        };
378        let rt = *self.frame_to_rt.get(&frame_id).unwrap_or(&0.0) as f64;
379
380        // Single lookup instead of contains_key + get
381        let Some((peptide_ids, abundances)) = self.frame_to_abundances.get(&frame_id) else {
382            return TimsFrameAnnotated::new(frame_id as i32, rt, ms_type, vec![], vec![], vec![], vec![], vec![], vec![]);
383        };
384
385        // Preallocate with estimated capacity
386        let estimated_capacity = peptide_ids.len() * 4;
387        let mut tims_spectra: Vec<TimsSpectrumAnnotated> = Vec::with_capacity(estimated_capacity);
388
389        for (peptide_id, abundance) in peptide_ids.iter().zip(abundances.iter()) {
390            // Single lookup
391            let Some((ion_abundances, scan_occurrences, scan_abundances, charges, _)) =
392                self.peptide_to_ions.get(peptide_id)
393            else {
394                continue;
395            };
396
397            // Cache peptide-level lookups
398            let total_events = *self.peptide_to_events.get(peptide_id).unwrap();
399            let peptide = self.peptides.get(peptide_id).unwrap();
400
401            for (index, ion_abundance) in ion_abundances.iter().enumerate() {
402                let scan_occurrence = &scan_occurrences[index];
403                let scan_abundance = &scan_abundances[index];
404                let charge = charges[index];
405
406                let ion = PeptideIon::new(
407                    peptide.sequence.sequence.clone(),
408                    charge as i32,
409                    *ion_abundance as f64,
410                    Some(*peptide_id as i32),
411                );
412                // TODO: make this configurable
413                let spectrum = ion.calculate_isotopic_spectrum_annotated(1e-3, 1e-8, 200, 1e-4);
414
415                for (scan, scan_abu) in scan_occurrence.iter().zip(scan_abundance.iter()) {
416                    let abundance_factor = abundance * ion_abundance * scan_abu * total_events;
417                    let scaled_spec: MzSpectrumAnnotated = spectrum.clone() * abundance_factor as f64;
418
419                    let mz_spectrum = if mz_noise_precursor {
420                        if uniform {
421                            scaled_spec.add_mz_noise_uniform_with_rng(precursor_noise_ppm, right_drag, &mut rng)
422                        } else {
423                            scaled_spec.add_mz_noise_normal_with_rng(precursor_noise_ppm, &mut rng)
424                        }
425                    } else {
426                        scaled_spec
427                    };
428
429                    // Cache scan mobility
430                    let scan_mobility = *self.scan_to_mobility.get(scan).unwrap() as f64;
431                    let spectrum_len = mz_spectrum.mz.len();
432
433                    tims_spectra.push(TimsSpectrumAnnotated::new(
434                        frame_id as i32,
435                        *scan,
436                        rt,
437                        scan_mobility,
438                        ms_type.clone(),
439                        vec![0; spectrum_len],
440                        mz_spectrum,
441                    ));
442                }
443            }
444        }
445
446        // Same empty-frame guard as build_precursor_frame: preserve this frame's
447        // id + ms_type rather than letting from_tims_spectra_annotated([]) emit a
448        // frame_id=1 / Unknown ghost.
449        if tims_spectra.is_empty() {
450            return TimsFrameAnnotated::new(
451                frame_id as i32, rt, ms_type, vec![], vec![], vec![], vec![], vec![], vec![],
452            );
453        }
454        let tims_frame = TimsFrameAnnotated::from_tims_spectra_annotated(tims_spectra);
455        let filtered_frame = tims_frame.filter_ranged(0.0, 2000.0, 0.0, 2.0, 0, 1000, 1.0, 1e9);
456
457        TimsFrameAnnotated {
458            frame_id: filtered_frame.frame_id,
459            retention_time: filtered_frame.retention_time,
460            ms_type: filtered_frame.ms_type,
461            tof: filtered_frame.tof,
462            mz: filtered_frame.mz,
463            scan: filtered_frame.scan,
464            inv_mobility: filtered_frame.inv_mobility,
465            intensity: filtered_frame.intensity,
466            annotations: filtered_frame
467                .annotations
468                .into_iter()
469                .map(|mut x| {
470                    x.contributions.sort_by(|a, b| {
471                        a.intensity_contribution
472                            .partial_cmp(&b.intensity_contribution)
473                            .unwrap()
474                    });
475                    x
476                })
477                .collect(),
478        }
479    }
480
481    pub fn build_precursor_frames_annotated(
482        &self,
483        frame_ids: Vec<u32>,
484        mz_noise_precursor: bool,
485        uniform: bool,
486        precursor_noise_ppm: f64,
487        right_drag: bool,
488        num_threads: usize,
489    ) -> Vec<TimsFrameAnnotated> {
490        let pool = rayon::ThreadPoolBuilder::new()
491            .num_threads(num_threads)
492            .build()
493            .unwrap();
494
495        pool.install(|| {
496            // Use indexed parallel iteration to maintain order, avoiding post-sort
497            let mut tims_frames: Vec<TimsFrameAnnotated> = Vec::with_capacity(frame_ids.len());
498            unsafe { tims_frames.set_len(frame_ids.len()); }
499
500            frame_ids.par_iter().enumerate().for_each(|(idx, frame_id)| {
501                let frame = self.build_precursor_frame_annotated(
502                    *frame_id,
503                    mz_noise_precursor,
504                    uniform,
505                    precursor_noise_ppm,
506                    right_drag,
507                );
508                unsafe {
509                    let ptr = tims_frames.as_ptr() as *mut TimsFrameAnnotated;
510                    std::ptr::write(ptr.add(idx), frame);
511                }
512            });
513
514            tims_frames
515        })
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    /// Hand-built single-frame builder: peptide 10 has an ion whose mobility was
524    /// captured on the scan grid as `scan_abundance = [0.3, 0.2]` (Σ = 0.5, i.e.
525    /// half its mass truncated by the grid); peptide 11 has an ion the grid
526    /// dropped entirely (no captured scans). Both peptides are in the frame with
527    /// `frame_abundance = 0.8` and `total_events = 1000`.
528    fn one_frame_builder() -> TimsTofSyntheticsPrecursorFrameBuilder {
529        let mut frame_to_abundances = BTreeMap::new();
530        frame_to_abundances.insert(1u32, (vec![10u32, 11u32], vec![0.8f32, 0.8f32]));
531
532        let mut peptide_to_ions = BTreeMap::new();
533        peptide_to_ions.insert(
534            10u32,
535            (
536                vec![1.0f32],
537                vec![vec![100u32, 101u32]],
538                vec![vec![0.3f32, 0.2f32]], // Σ = 0.5 captured on the grid
539                vec![1i8],
540                vec![MzSpectrum::new(vec![500.0], vec![1.0])],
541            ),
542        );
543        peptide_to_ions.insert(
544            11u32,
545            (
546                vec![1.0f32],
547                vec![vec![]], // grid captured no scans for this ion
548                vec![vec![]],
549                vec![1i8],
550                vec![MzSpectrum::new(vec![700.0], vec![1.0])],
551            ),
552        );
553
554        let mut peptide_to_events = BTreeMap::new();
555        peptide_to_events.insert(10u32, 1000.0f32);
556        peptide_to_events.insert(11u32, 1000.0f32);
557
558        let mut frame_to_rt = BTreeMap::new();
559        frame_to_rt.insert(1u32, 60.0f32);
560
561        TimsTofSyntheticsPrecursorFrameBuilder {
562            noise_seed: 0,
563            ions: BTreeMap::new(),
564            peptides: BTreeMap::new(),
565            scans: Vec::new(),
566            frames: Vec::new(),
567            precursor_frame_id_set: HashSet::new(),
568            frame_to_abundances,
569            peptide_to_ions,
570            frame_to_rt,
571            scan_to_mobility: BTreeMap::new(),
572            peptide_to_events,
573            ion_id_to_peptide_charge: BTreeMap::new(),
574        }
575    }
576
577    fn total_intensity(s: &MzSpectrum) -> f64 {
578        s.intensity.iter().sum()
579    }
580
581    #[test]
582    fn astral_marginal_uses_full_mobility_and_recovers_grid_edge_ions() {
583        let b = one_frame_builder();
584
585        // Captured-grid sum: ion 10 weighted by Σ scan_abundance = 0.5, so
586        //   0.8 * 1.0 * 1000 * 0.5 = 400 at m/z 500. Ion 11 dropped (no scans).
587        let grid = b.precursor_scan_spectrum(1);
588        assert!((total_intensity(&grid) - 400.0).abs() < 1e-2, "grid total {}", total_intensity(&grid));
589        assert_eq!(grid.mz.len(), 1, "grid drops the edge ion (no captured scans)");
590        assert!((grid.mz[0] - 500.0).abs() < 1e-9);
591
592        // Full mobility marginal (non-IMS physics): scan factor folded to 1.0, and
593        // the edge ion 11 is recovered at full abundance.
594        //   ion 10: 0.8 * 1.0 * 1000 * 1.0 = 800 at m/z 500
595        //   ion 11: 0.8 * 1.0 * 1000 * 1.0 = 800 at m/z 700
596        let marginal = b.precursor_scan_marginal_spectrum(1);
597        assert!((total_intensity(&marginal) - 1600.0).abs() < 1e-2, "marginal total {}", total_intensity(&marginal));
598        assert_eq!(marginal.mz.len(), 2, "marginal keeps both ions");
599        assert!(marginal.mz.iter().any(|&m| (m - 700.0).abs() < 1e-9), "edge ion recovered");
600
601        // The marginal is the principled non-IMS value; the grid here under-counts
602        // (truncation). It is NOT a theorem that marginal >= grid per frame — grid
603        // binning can also over-count when adjacent scan intervals overlap.
604        assert!(total_intensity(&marginal) > total_intensity(&grid));
605
606        // The MS1 scan render must carry exactly the marginal spectrum.
607        let RenderedEvent::Scan { ms_level, isolation, spectrum, .. } =
608            b.render_precursor_scan(1, DataMode::Profile)
609        else {
610            panic!("MS1 render must be a Scan");
611        };
612        assert_eq!(ms_level, 1);
613        assert!(isolation.is_none());
614        assert_eq!(spectrum.intensity, *marginal.intensity);
615        assert_eq!(spectrum.mz, *marginal.mz);
616    }
617}