Skip to main content

rustdf/sim/
projector.rs

1//! Instrument-dispatch P2: the projector.
2//!
3//! Turns the vendor-neutral trunk (scalar [`PeptideScalar`] / [`IonScalar`]
4//! physics) into device-sampled signal, parameterised by the instrument's
5//! sampling geometry and an absolute event timeline derived from the
6//! [`AcquisitionScheme`]. This replaces the two device-projection jobs that the
7//! legacy pipeline baked into the DB (`frame_occurrence/abundance` and
8//! `scan_occurrence/abundance`) with a render-time computation:
9//!
10//! * **time projection** (all instruments): the EMG RT profile integrated over
11//!   each event's true `[start, end]` interval — [`mscore`]'s
12//!   `project_emg_over_events` (the corrected, non-fixed-cycle kernel).
13//! * **mobility projection** (IMS only): the ion mobility Gaussian sampled onto
14//!   the scan grid via the existing gaussian kernels; for a non-IMS instrument
15//!   (`MobilityModality::None`) the distribution is marginalised onto a single
16//!   scan, conserving total signal.
17//!
18//! P2 builds and verifies these projections in isolation. Wiring `assemble_frames`
19//! onto them (with a DB fallback) and the byte-parity proof are P3.
20
21use crate::sim::containers::{IonScalar, MobilityEnv, PeptideScalar, ScansSim};
22use crate::sim::scheme::{
23    AcquisitionEvent, AcquisitionScheme, ActivationPolicy, DataMode, InstrumentCapabilities,
24    InstrumentKind, IsolationWindow, RepeatPolicy,
25};
26use mscore::algorithm::utility::{
27    calculate_abundance_gaussian, calculate_frame_abundances_emg_par,
28    calculate_frame_occurrences_emg_par, calculate_scan_abundances_gaussian_par,
29    calculate_scan_occurrence_gaussian, calculate_scan_occurrences_gaussian_par, normal_cdf_range,
30    project_emg_over_events_par,
31};
32use std::collections::HashMap;
33
34// --------------------------------------------------------------------------- //
35// Sampling geometry (instrument branch 1)
36// --------------------------------------------------------------------------- //
37
38/// Ion-mobility separation modality. Only the cases the engine handles today are
39/// listed; `DriftTube`/`Faims` are reserved for later instruments.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MobilityModality {
42    /// No mobility axis (Orbitrap / Astral): ions collapse onto the time axis.
43    None,
44    /// timsTOF trapped ion mobility: a discrete scan grid.
45    Tims,
46}
47
48/// The instrument's mobility sampling geometry. For `Tims`, `inv_mobility` holds
49/// the per-scan 1/K0 grid in **ascending** order (the order the gaussian
50/// occurrence kernel expects internally). [`project_mobility_ion`] returns scan
51/// indices as positions into this ascending vector; callers that need native
52/// (descending) Bruker scan numbers map at the device boundary.
53#[derive(Debug, Clone)]
54pub struct SamplingGeometry {
55    pub modality: MobilityModality,
56    pub inv_mobility: Vec<f64>,
57}
58
59impl SamplingGeometry {
60    /// A non-IMS geometry (single virtual scan).
61    pub fn none() -> Self {
62        SamplingGeometry { modality: MobilityModality::None, inv_mobility: Vec::new() }
63    }
64
65    /// A timsTOF geometry from a per-scan 1/K0 grid.
66    pub fn tims(inv_mobility: Vec<f64>) -> Self {
67        SamplingGeometry { modality: MobilityModality::Tims, inv_mobility }
68    }
69
70    /// Build a timsTOF geometry from the reference dataset's `scans` table. The
71    /// 1/K0 values are sorted ascending to satisfy the kernel's input contract
72    /// regardless of the table's native scan ordering. Non-finite mobilities are
73    /// dropped (rather than panicking the sort).
74    pub fn from_scans(scans: &[ScansSim]) -> Self {
75        let mut inv: Vec<f64> = scans
76            .iter()
77            .map(|s| s.mobility as f64)
78            .filter(|m| m.is_finite())
79            .collect();
80        inv.sort_by(f64::total_cmp);
81        SamplingGeometry::tims(inv)
82    }
83
84    /// Number of scans in the grid (1 for the marginalised non-IMS case).
85    pub fn num_scans(&self) -> usize {
86        match self.modality {
87            MobilityModality::None => 1,
88            MobilityModality::Tims => self.inv_mobility.len(),
89        }
90    }
91}
92
93// --------------------------------------------------------------------------- //
94// Instrument config (the dispatch bundle threaded through the render core)
95// --------------------------------------------------------------------------- //
96
97/// The vendor-specific configuration the render core dispatches on (P6c). Bundles
98/// the three orthogonal axes that decide how a vendor-neutral trunk is recorded:
99///
100/// * [`InstrumentKind`] — vendor identity (selects the writer at the boundary);
101/// * [`InstrumentCapabilities`] — what physics applies (gates mobility / quad
102///   isotope transmission; an Astral forces isotope mode to `None`);
103/// * [`MobilityModality`] — whether events render as `MobilityFrame` (TIMS) or a
104///   single collapsed `Scan` (non-IMS);
105/// * [`ActivationPolicy`] — how the collision energy + its *unit* are produced
106///   (Bruker eV-per-scan vs Thermo NCE-per-window), so a fragment predictor can
107///   reject a unit it was not calibrated for.
108///
109/// The Bruker default reproduces current behaviour exactly; `astral` is the first
110/// non-IMS instrument. This is pure configuration — it holds no analyte state.
111#[derive(Debug, Clone, Copy)]
112pub struct InstrumentConfig {
113    pub kind: InstrumentKind,
114    pub capabilities: InstrumentCapabilities,
115    pub mobility: MobilityModality,
116    pub activation: ActivationPolicy,
117}
118
119impl InstrumentConfig {
120    /// Bruker timsTOF DDA-PASEF: TIMS mobility, full capabilities, eV CE linear in
121    /// scan. `ce_bias`/`ce_slope` reproduce the legacy `dda_selection_scheme`
122    /// formula (defaults 54.1984 / -0.0345). Matches the pre-dispatch behaviour.
123    pub fn bruker_pasef(ce_bias: f64, ce_slope: f64) -> Self {
124        InstrumentConfig {
125            kind: InstrumentKind::TimsTofDia,
126            capabilities: InstrumentCapabilities::bruker_timstof(),
127            mobility: MobilityModality::Tims,
128            activation: ActivationPolicy::bruker_pasef(ce_bias, ce_slope),
129        }
130    }
131
132    /// Orbitrap Astral: no mobility axis (events collapse to a single `Scan`),
133    /// Astral capabilities (both false — isotope transmission forced to `None`),
134    /// and a Thermo NCE activation policy carrying the per-window normalized CE.
135    ///
136    /// Takes the per-window collision-energy *policy* (not a full
137    /// [`ActivationPolicy`]) and constructs the NCE activation internally, so an
138    /// Astral config can never be built with a contradictory eV / scan-dependent
139    /// (e.g. Bruker-PASEF) activation model. `ce` is in NCE units by construction.
140    pub fn astral(ce: crate::sim::scheme::CollisionEnergyPolicy) -> Self {
141        InstrumentConfig {
142            kind: InstrumentKind::OrbitrapAstral,
143            capabilities: InstrumentCapabilities::astral(),
144            mobility: MobilityModality::None,
145            activation: ActivationPolicy::thermo_nce(ce),
146        }
147    }
148
149    /// Whether events render as a single collapsed [`RenderedEvent::Scan`] (no
150    /// mobility axis) rather than a [`RenderedEvent::MobilityFrame`].
151    pub fn is_scan_based(&self) -> bool {
152        self.mobility == MobilityModality::None
153    }
154}
155
156// --------------------------------------------------------------------------- //
157// Event timeline (schedule expansion — branch 2 laid out over the gradient)
158// --------------------------------------------------------------------------- //
159
160/// One concrete acquisition event placed on the absolute run timeline, with its
161/// `[start_s, end_s]` exposure interval (the input to the time projection).
162#[derive(Debug, Clone)]
163pub struct EventSlot {
164    /// Global event index over the whole run (0-based, acquisition order).
165    pub global_index: usize,
166    pub cycle_index: u64,
167    /// Position within the cycle (0 = the MS1).
168    pub event_in_cycle: usize,
169    pub ms_level: u8,
170    /// Exposure interval (seconds) the analyte signal is integrated over.
171    pub interval: (f64, f64),
172}
173
174/// The full run as an ordered list of [`EventSlot`]s.
175#[derive(Debug, Clone)]
176pub struct EventTimeline {
177    pub events: Vec<EventSlot>,
178}
179
180impl EventTimeline {
181    /// Expand an [`AcquisitionScheme`] into absolute event intervals over the
182    /// gradient. Each cycle is laid out starting at `start_time_s + k*cycle_time`;
183    /// within a cycle, events are placed back-to-back using their `duration_s`
184    /// when present, otherwise the cycle time is split uniformly across events.
185    pub fn from_scheme(scheme: &AcquisitionScheme) -> Result<Self, String> {
186        // Rely on the scheme invariants (exactly one MS1, first; >=1 MS2) so the
187        // MS1 ordinal == cycle index downstream (see `project_time`).
188        scheme.validate()?;
189        let RepeatPolicy::FixedCycleTime { cycle_time_s, gradient_length_s, start_time_s } =
190            scheme.repeat;
191        if !(cycle_time_s.is_finite() && cycle_time_s > 0.0) {
192            return Err("cycle_time_s must be finite and > 0".into());
193        }
194        if !start_time_s.is_finite() || start_time_s < 0.0 {
195            return Err("start_time_s must be finite and >= 0".into());
196        }
197        let n_cycles = scheme.num_cycles().ok_or("scheme has no derivable cycle count")?;
198        let events_per_cycle = scheme.cycle.len();
199        if events_per_cycle == 0 {
200            return Err("empty cycle".into());
201        }
202
203        // Per-event durations within one cycle: explicit `duration_s` are honored;
204        // the cycle time left over after the explicit ones is split equally among
205        // the unspecified events. Explicit durations summing beyond the cycle time
206        // (which would overrun into the next cycle) is an error.
207        let explicit: Vec<Option<f64>> = scheme
208            .cycle
209            .iter()
210            .map(|e| {
211                let d = match e {
212                    AcquisitionEvent::Ms1(m) => m.duration_s,
213                    AcquisitionEvent::DiaMs2Frame(f) => f.duration_s,
214                };
215                d.filter(|v| v.is_finite() && *v > 0.0)
216            })
217            .collect();
218        let explicit_sum: f64 = explicit.iter().flatten().sum();
219        let n_unspecified = explicit.iter().filter(|d| d.is_none()).count();
220        if explicit_sum > cycle_time_s + 1e-9 {
221            return Err(format!(
222                "explicit event durations ({explicit_sum}) exceed cycle_time_s ({cycle_time_s})"
223            ));
224        }
225        // Unspecified events split the remaining cycle time; if the explicit
226        // durations leave nothing for them, the schedule would emit zero-exposure
227        // (no-signal) events — reject it rather than silently drop their signal.
228        let per_unspecified = if n_unspecified > 0 {
229            let remaining = cycle_time_s - explicit_sum;
230            if remaining <= 1e-12 {
231                return Err(format!(
232                    "explicit durations ({explicit_sum}) leave no time for {n_unspecified} \
233                     unspecified event(s) in cycle_time_s {cycle_time_s}"
234                ));
235            }
236            remaining / n_unspecified as f64
237        } else {
238            0.0
239        };
240        let durations: Vec<f64> =
241            explicit.iter().map(|d| d.unwrap_or(per_unspecified)).collect();
242
243        let mut events = Vec::with_capacity(n_cycles as usize * events_per_cycle);
244        let mut global_index = 0usize;
245        for k in 0..n_cycles {
246            let cycle_start = start_time_s + k as f64 * cycle_time_s;
247            // Half-open run [start, gradient): num_cycles already floors to full
248            // cycles, so this only guards float edge cases.
249            if cycle_start >= gradient_length_s {
250                break;
251            }
252            let mut t = cycle_start;
253            for (j, ev) in scheme.cycle.iter().enumerate() {
254                let start = t;
255                let end = t + durations[j];
256                t = end;
257                let ms_level = match ev {
258                    AcquisitionEvent::Ms1(_) => 1,
259                    AcquisitionEvent::DiaMs2Frame(_) => 2,
260                };
261                events.push(EventSlot {
262                    global_index,
263                    cycle_index: k,
264                    event_in_cycle: j,
265                    ms_level,
266                    interval: (start, end),
267                });
268                global_index += 1;
269            }
270        }
271        Ok(EventTimeline { events })
272    }
273
274    /// The MS1 events only, in order (their `[start,end]` intervals drive the
275    /// precursor time projection).
276    pub fn ms1_intervals(&self) -> Vec<(f64, f64)> {
277        self.events
278            .iter()
279            .filter(|e| e.ms_level == 1)
280            .map(|e| e.interval)
281            .collect()
282    }
283}
284
285// --------------------------------------------------------------------------- //
286// RenderedEvent — the frozen output/writer-boundary interface (§3.2)
287// --------------------------------------------------------------------------- //
288
289/// Which coordinate space a rendered spectrum's m/z values are in.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum MzCoordSpace {
292    /// Exact physical m/z (projector output, pre-detector).
293    Physical,
294    /// Native TOF index (Bruker).
295    NativeTof,
296    /// Native frequency (Thermo).
297    NativeFreq,
298}
299
300/// Point on the intensity-conservation chain (mirrors the Python
301/// `dispatch.IntensityStage`; see plan §3.5).
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum IntensityStage {
304    Yield,
305    Time,
306    Mobility,
307    Transmitted,
308    Detected,
309    Centroided,
310}
311
312/// A spectrum tagged with where it sits in the coordinate/intensity pipeline, so
313/// writers and the detector model never double-apply a transform.
314#[derive(Debug, Clone)]
315pub struct RenderedSpectrum {
316    pub mz: Vec<f64>,
317    pub intensity: Vec<f64>,
318    pub coords: MzCoordSpace,
319    pub mode: DataMode,
320    pub detector_applied: bool,
321    pub stage: IntensityStage,
322}
323
324/// One acquisition event's rendered signal. `Scan` for non-IMS instruments;
325/// `MobilityFrame` for IMS (preserving per-scan simultaneity rather than
326/// flattening + rebundling).
327#[derive(Debug, Clone)]
328pub enum RenderedEvent {
329    Scan {
330        ms_level: u8,
331        retention_time_s: f64,
332        isolation: Option<IsolationWindow>,
333        spectrum: RenderedSpectrum,
334    },
335    MobilityFrame {
336        ms_level: u8,
337        retention_time_s: f64,
338        scans: Vec<(u32, RenderedSpectrum)>,
339    },
340}
341
342// --------------------------------------------------------------------------- //
343// Projection mode
344// --------------------------------------------------------------------------- //
345
346/// Which projection math to use.
347///
348/// `Accurate` is the default for new/non-Bruker dispatch (event-interval time
349/// integration + per-scan mobility bins). `LegacyCompat` faithfully mirrors the
350/// pre-dispatch pipeline's kernels and parameters — fixed `[t-cycle,t]` frame
351/// bins, fixed `im_cycle_length` mobility bins, the kernels' native index
352/// conventions — so it byte-reproduces a legacy Bruker `.d` for the parity gate.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum ProjectionMode {
355    LegacyCompat,
356    Accurate,
357}
358
359/// Parameters the projector needs to reproduce / improve the legacy distribution
360/// jobs. Defaults mirror the simulator's config defaults (`target_p=0.999`,
361/// `sampling_step_size=0.001`, scan step `0.0001`, `n_steps=1000`,
362/// `remove_epsilon=1e-4`).
363#[derive(Debug, Clone, Copy)]
364pub struct ProjectionParams {
365    pub target_p: f64,
366    pub frame_step_size: f64,
367    pub scan_step_size: f64,
368    pub n_steps: Option<usize>,
369    pub remove_epsilon: f64,
370    pub num_threads: usize,
371    /// Decimal places the legacy / `project_distributions` writer rounds stored
372    /// abundances to (`python_list_to_json_string` num_decimals, default 4).
373    /// LegacyCompat rounds to this so projector-fed rendering byte-matches the
374    /// columns a DB was written with. Accurate ignores it (full precision).
375    pub num_decimals: u32,
376}
377
378impl Default for ProjectionParams {
379    fn default() -> Self {
380        ProjectionParams {
381            target_p: 0.999,
382            frame_step_size: 0.001,
383            scan_step_size: 0.0001,
384            n_steps: Some(1000),
385            remove_epsilon: 1e-4,
386            num_threads: 4,
387            num_decimals: 4,
388        }
389    }
390}
391
392/// Where the per-analyte occurrence/abundance distributions come from when
393/// constructing the builder entities (P4 canonical-state contract).
394///
395/// `Columns` parses them from the legacy JSON columns (the default — byte
396/// behaviour unchanged). `Projector` computes them at load time from the scalar
397/// trunk entities, so the columns become unnecessary (the P4 goal). Both paths
398/// produce identical-shape `PeptidesSim`/`IonSim`, so every downstream consumer
399/// (maps, transmission, DDA, fragment-intensity) is untouched.
400#[derive(Debug, Clone, Copy)]
401pub enum DistributionSource {
402    Columns,
403    Projector { mode: ProjectionMode, env: MobilityEnv, params: ProjectionParams },
404}
405
406// --------------------------------------------------------------------------- //
407// LegacyCompat projections (parity target — mirror the legacy kernels exactly)
408// --------------------------------------------------------------------------- //
409
410/// LegacyCompat time projection: reproduce `frame_occurrence`/`frame_abundance`
411/// over the **full** frames table, exactly as `simulate_frame_distributions_emg`
412/// did. Inputs are taken at **f64** (the precision the legacy pipeline used) —
413/// NOT the f32 trunk entities — so the result is bit-faithful: `rt_mus`,
414/// `rt_sigmas`, `rt_lambdas` are per-peptide EMG params (`rt_mu`, not the GRU
415/// apex); `frame_ids`/`frame_times` are the whole frames table ascending by id.
416/// Occurrence positions (1-indexed into `frame_times`) are translated to frame
417/// ids via `frame_ids[pos-1]`; the `remove_epsilon` filter drops frames whose
418/// abundance is `<= remove_epsilon` (matching the legacy job). Returns
419/// `(frame_id, abundance)` per peptide.
420pub fn project_time_legacy(
421    rt_mus: &[f64],
422    rt_sigmas: &[f64],
423    rt_lambdas: &[f64],
424    frame_ids: &[u32],
425    frame_times: &[f64],
426    rt_cycle_length: f64,
427    target_p: f64,
428    step_size: f64,
429    n_steps: Option<usize>,
430    remove_epsilon: f64,
431    num_threads: usize,
432) -> Vec<Vec<(u32, f64)>> {
433    assert_eq!(frame_ids.len(), frame_times.len(), "frame_ids/frame_times length mismatch");
434    let n = frame_ids.len();
435    let nt = num_threads.max(1);
436    // Batched + parallel, exactly the kernels the legacy job used.
437    let positions = calculate_frame_occurrences_emg_par(
438        frame_times,
439        rt_mus.to_vec(),
440        rt_sigmas.to_vec(),
441        rt_lambdas.to_vec(),
442        target_p,
443        step_size,
444        nt,
445        n_steps,
446    );
447    // Translate 1-indexed positions -> frame ids (robust to non-1..N).
448    let occ_ids: Vec<Vec<i32>> = positions
449        .iter()
450        .map(|ps| {
451            ps.iter()
452                .map(|&p| {
453                    assert!(p >= 1 && (p as usize) <= n, "occurrence position {p} out of range");
454                    frame_ids[(p - 1) as usize] as i32
455                })
456                .collect()
457        })
458        .collect();
459    // Abundance keyed by frame id (we pass ids as occurrences).
460    let time_map: HashMap<i32, f64> =
461        frame_ids.iter().zip(frame_times).map(|(&id, &t)| (id as i32, t)).collect();
462    let abund = calculate_frame_abundances_emg_par(
463        &time_map,
464        occ_ids.clone(),
465        rt_mus.to_vec(),
466        rt_sigmas.to_vec(),
467        rt_lambdas.to_vec(),
468        rt_cycle_length,
469        nt,
470        n_steps,
471    );
472    occ_ids
473        .into_iter()
474        .zip(abund)
475        .map(|(ids, ab)| {
476            ids.into_iter()
477                .map(|id| id as u32)
478                .zip(ab)
479                .filter(|(_, a)| *a > remove_epsilon)
480                .collect()
481        })
482        .collect()
483}
484
485/// Batched + parallel LegacyCompat scan projection over many ions (the kernels
486/// the legacy job used). `means`/`sigmas` are the original per-ion 1/K0
487/// mean+std; `scan_ids`/`scan_mobilities` ascending+aligned (shared by all ions).
488/// Returns one `(scan, abundance)` list per ion. Requires positive sigmas (real
489/// data); use [`project_mobility_ion_legacy`] for the point-mass-guarded single-
490/// ion path.
491pub fn project_mobility_legacy_par(
492    means: &[f64],
493    sigmas: &[f64],
494    scan_ids: &[u32],
495    scan_mobilities: &[f64],
496    im_cycle_length: f64,
497    target_p: f64,
498    step_size: f64,
499    num_threads: usize,
500) -> Vec<Vec<(i32, f64)>> {
501    assert_eq!(scan_ids.len(), scan_mobilities.len(), "scan_ids/mobilities length mismatch");
502    let nt = num_threads.max(1);
503    let occ = calculate_scan_occurrences_gaussian_par(
504        scan_mobilities,
505        means.to_vec(),
506        sigmas.to_vec(),
507        target_p,
508        step_size,
509        5.0,
510        5.0,
511        nt,
512    );
513    let time_map: HashMap<i32, f64> =
514        scan_ids.iter().zip(scan_mobilities).map(|(&id, &m)| (id as i32, m)).collect();
515    let abund = calculate_scan_abundances_gaussian_par(
516        &time_map,
517        occ.clone(),
518        means.to_vec(),
519        sigmas.to_vec(),
520        im_cycle_length,
521        nt,
522    );
523    occ.into_iter()
524        .zip(abund)
525        .map(|(o, a)| o.into_iter().zip(a).collect())
526        .collect()
527}
528
529/// LegacyCompat mobility projection for one ion: reproduce
530/// `scan_occurrence`/`scan_abundance` exactly as
531/// `simulate_scan_distributions_with_variance` did — `calculate_scan_occurrence_
532/// gaussian` with `n_lower/upper = 5`, and `calculate_abundance_gaussian` over
533/// the fixed `im_cycle_length` bin. `mean`/`sigma` are the **original** stored
534/// 1/K0 mean + std at f64 (NOT a CCS round-trip). `scan_mobilities` must be
535/// **ascending** with `scan_ids` aligned (exactly as the legacy job feeds `scans`
536/// sorted so `im_cycle_length > 0`); a descending grid makes the occurrence
537/// kernel return nothing. The occurrence kernel's indices are the values the
538/// legacy job stored directly, so they are returned as-is. Returns
539/// `(scan, abundance)`.
540pub fn project_mobility_ion_legacy(
541    mean: f64,
542    sigma: f64,
543    scan_ids: &[u32],
544    scan_mobilities: &[f64],
545    im_cycle_length: f64,
546    target_p: f64,
547    step_size: f64,
548) -> Vec<(i32, f64)> {
549    assert_eq!(scan_ids.len(), scan_mobilities.len(), "scan_ids/mobilities length mismatch");
550    if scan_ids.is_empty() {
551        return Vec::new();
552    }
553    // Point mass (zero/invalid spread): single nearest scan, no CDF div-by-zero.
554    if !(sigma > 0.0) {
555        let idx = nearest_scan_ascending(scan_mobilities, mean);
556        return vec![(scan_ids[idx] as i32, 1.0)];
557    }
558    let occ =
559        calculate_scan_occurrence_gaussian(scan_mobilities, mean, sigma, target_p, step_size, 5.0, 5.0);
560    let time_map: HashMap<i32, f64> =
561        scan_ids.iter().zip(scan_mobilities).map(|(&id, &m)| (id as i32, m)).collect();
562    let abund = calculate_abundance_gaussian(&time_map, &occ, mean, sigma, im_cycle_length);
563    occ.into_iter().zip(abund).collect()
564}
565
566/// Index of the grid entry nearest to `value` (grid assumed non-empty).
567fn nearest_scan_ascending(grid: &[f64], value: f64) -> usize {
568    grid.iter()
569        .enumerate()
570        .min_by(|(_, a), (_, b)| {
571            (**a - value)
572                .abs()
573                .partial_cmp(&(**b - value).abs())
574                .unwrap_or(std::cmp::Ordering::Equal)
575        })
576        .map(|(i, _)| i)
577        .unwrap_or(0)
578}
579
580// --------------------------------------------------------------------------- //
581// Projections (Accurate)
582// --------------------------------------------------------------------------- //
583
584/// Time projection: for each peptide, the `(global_event_index, abundance)` list
585/// over **every** acquisition event in the run (MS1 and MS2 alike), integrating
586/// the EMG over each event's true `[start, end]` exposure interval. The index is
587/// the position in `timeline.events` (i.e. `EventSlot::global_index`), so MS2
588/// events receive their own RT abundance rather than reusing the MS1 value — this
589/// matches the legacy `frame_occurrence`/`frame_abundance`, which span all frames.
590pub fn project_time(
591    peptides: &[PeptideScalar],
592    timeline: &EventTimeline,
593    target_p: f64,
594    step_size: f64,
595    num_threads: usize,
596) -> Vec<Vec<(usize, f64)>> {
597    // Every event's interval, indexed by global event position.
598    let intervals: Vec<(f64, f64)> = timeline.events.iter().map(|e| e.interval).collect();
599    // The EMG is centred on rt_mu (the location parameter), not the GRU apex.
600    let rts: Vec<f64> = peptides.iter().map(|p| p.rt_mu as f64).collect();
601    let sigmas: Vec<f64> = peptides.iter().map(|p| p.rt_sigma as f64).collect();
602    let lambdas: Vec<f64> = peptides.iter().map(|p| p.rt_lambda as f64).collect();
603    project_emg_over_events_par(
604        &intervals,
605        rts,
606        sigmas,
607        lambdas,
608        target_p,
609        step_size,
610        num_threads.max(1), // 0 would panic the rayon pool builder
611        None,
612    )
613}
614
615/// Mobility projection for a single ion: `(scan_index, abundance)` over the
616/// geometry's scan grid, scan indices being positions into the **ascending**
617/// `geometry.inv_mobility` (per the [`SamplingGeometry`] contract).
618///
619/// Each occupied scan's abundance is the mobility Gaussian's CDF over that
620/// scan's own bin — the midpoints to its neighbours on the (possibly
621/// non-uniform) calibrated grid — so dense/sparse regions are integrated
622/// correctly (a single mean spacing would overlap/gap them).
623///
624/// Intensity-contract note: for `MobilityModality::None` the result is the full
625/// marginal `[(0, 1.0)]` by definition (no mobility axis, complete integration).
626/// For `Tims` the per-scan abundances sum to the captured mass, which is `<= 1`
627/// (grid clipping + `target_p` truncation) — the two cases are intentionally
628/// different and downstream normalisation must account for it.
629pub fn project_mobility_ion(
630    ion: &IonScalar,
631    geometry: &SamplingGeometry,
632    env: &MobilityEnv,
633    target_p: f64,
634    step_size: f64,
635) -> Vec<(i32, f64)> {
636    match geometry.modality {
637        MobilityModality::None => vec![(0, 1.0)],
638        MobilityModality::Tims => project_mobility_gaussian(
639            &geometry.inv_mobility,
640            ion.inv_mobility(env),
641            ion.inv_mobility_std as f64,
642            target_p,
643            step_size,
644        ),
645    }
646}
647
648/// Accurate mobility projection core: a Gaussian (`mean`, `sigma`) in 1/K0 onto
649/// an **ascending** mobility grid, returning `(ascending_scan_index, abundance)`
650/// where each occupied scan's abundance is the CDF over that scan's own
651/// midpoint-bounded bin (correct on non-uniform grids). σ ≤ 0 is a point mass at
652/// the nearest scan; empty/singleton grids are handled.
653pub fn project_mobility_gaussian(
654    grid_ascending: &[f64],
655    mean: f64,
656    sigma: f64,
657    target_p: f64,
658    step_size: f64,
659) -> Vec<(i32, f64)> {
660    let n = grid_ascending.len();
661    if n == 0 {
662        return Vec::new();
663    }
664    if n == 1 {
665        if !(sigma > 0.0) {
666            return vec![(0, 1.0)];
667        }
668        let a = normal_cdf_range(mean - 6.0 * sigma, mean + 6.0 * sigma, mean, sigma);
669        return vec![(0, a)];
670    }
671    // Point mass (zero/invalid spread): single nearest scan, no CDF div-by-zero.
672    if !(sigma > 0.0) {
673        let scan = nearest_scan_ascending(grid_ascending, mean);
674        return vec![(scan as i32, 1.0)];
675    }
676    // `calculate_scan_occurrence_gaussian` returns indices into the REVERSED
677    // (descending) grid: occ index i -> grid_ascending[n-1-i].
678    let occ =
679        calculate_scan_occurrence_gaussian(grid_ascending, mean, sigma, target_p, step_size, 3.0, 3.0);
680    let rev: Vec<f64> = grid_ascending.iter().rev().copied().collect();
681    let mut out: Vec<(i32, f64)> = occ
682        .into_iter()
683        .map(|i| {
684            let i = i as usize;
685            let v = rev[i];
686            // Bin edges = midpoints to neighbours on the descending grid;
687            // endpoints extrapolate by their adjacent half-spacing.
688            let hi = if i > 0 { (rev[i - 1] + v) / 2.0 } else { v + (v - rev[1]) / 2.0 };
689            let lo = if i + 1 < n { (v + rev[i + 1]) / 2.0 } else { v - (rev[i - 1] - v) / 2.0 };
690            let abundance = normal_cdf_range(lo, hi, mean, sigma);
691            ((n - 1 - i) as i32, abundance) // reversed index -> ascending position
692        })
693        .collect();
694    out.sort_by_key(|(scan, _)| *scan);
695    out
696}
697
698/// Batched + parallel Accurate mobility projection over many ions sharing one
699/// ascending mobility grid. `means`/`sigmas` are per-ion 1/K0 mean+std. Returns
700/// one `(ascending_scan_index, abundance)` list per ion.
701pub fn project_mobility_accurate_par(
702    means: &[f64],
703    sigmas: &[f64],
704    grid_ascending: &[f64],
705    target_p: f64,
706    step_size: f64,
707    num_threads: usize,
708) -> Vec<Vec<(i32, f64)>> {
709    use rayon::prelude::*;
710    use rayon::ThreadPoolBuilder;
711    let pool = ThreadPoolBuilder::new().num_threads(num_threads.max(1)).build().unwrap();
712    pool.install(|| {
713        means
714            .par_iter()
715            .zip(sigmas.par_iter())
716            .map(|(&m, &s)| project_mobility_gaussian(grid_ascending, m, s, target_p, step_size))
717            .collect()
718    })
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use crate::sim::scheme::{
725        Analyzer, CollisionEnergyPolicy, DiaGeometry, DiaMs2Frame, DiaWindow,
726        EnergyUnit, InstrumentKind, Ms1Event, Provenance, SchemeSource,
727    };
728    use mscore::data::spectrum::MzSpectrum;
729
730    #[test]
731    fn instrument_config_bundles_the_dispatch_axes() {
732        // P6c: Bruker config = TIMS mobility, full capabilities, eV CE, frames.
733        let bruker = InstrumentConfig::bruker_pasef(54.1984, -0.0345);
734        assert_eq!(bruker.kind, InstrumentKind::TimsTofDia);
735        assert_eq!(bruker.mobility, MobilityModality::Tims);
736        assert!(bruker.capabilities.has_tims_mobility);
737        assert!(bruker.capabilities.has_quad_isotope_transmission);
738        assert!(!bruker.is_scan_based());
739        assert_eq!(bruker.activation.unit, EnergyUnit::ElectronVolt);
740        assert_eq!(bruker.activation.collision_energy_for_scan(250), Some(54.1984 - 0.0345 * 250.0));
741
742        // Astral config = no mobility (scan-based), both capabilities off, NCE.
743        // The constructor builds the NCE activation internally from the CE policy,
744        // so a contradictory eV/scan-dependent activation is unrepresentable.
745        let astral = InstrumentConfig::astral(CollisionEnergyPolicy::Value(27.0));
746        assert_eq!(astral.kind, InstrumentKind::OrbitrapAstral);
747        assert_eq!(astral.mobility, MobilityModality::None);
748        assert!(!astral.capabilities.has_tims_mobility);
749        assert!(!astral.capabilities.has_quad_isotope_transmission);
750        assert!(astral.is_scan_based());
751        assert_eq!(astral.activation.unit, EnergyUnit::NormalizedCe);
752        // No IMS: the Astral policy has no scan-parameterised CE.
753        assert_eq!(astral.activation.collision_energy_for_scan(250), None);
754    }
755
756    fn scheme_one_ms1_two_ms2() -> AcquisitionScheme {
757        let win = |c: f64| DiaWindow {
758            isolation: IsolationWindow { center_mz: c, width_mz: 25.0 },
759            collision_energy: CollisionEnergyPolicy::Value(25.0),
760            geometry: DiaGeometry::MzOnly,
761        };
762        let ms2 = |c: f64| {
763            AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
764                windows: vec![win(c)],
765                analyzer: Analyzer::Tof,
766                data_mode: DataMode::Centroid,
767                duration_s: None,
768                vendor_group_id: None,
769            })
770        };
771        AcquisitionScheme {
772            version: 1,
773            instrument: InstrumentKind::TimsTofDia,
774            cycle: vec![
775                AcquisitionEvent::Ms1(Ms1Event {
776                    analyzer: Analyzer::Tof,
777                    data_mode: DataMode::Profile,
778                    mz_range: Some((100.0, 1700.0)),
779                    duration_s: None,
780                }),
781                ms2(450.0),
782                ms2(550.0),
783            ],
784            repeat: RepeatPolicy::FixedCycleTime {
785                cycle_time_s: 1.2,
786                gradient_length_s: 12.0,
787                start_time_s: 0.0,
788            },
789            mz_range: (100.0, 1700.0),
790            provenance: Provenance { source: SchemeSource::Programmatic, notes: String::new() },
791        }
792    }
793
794    #[test]
795    fn timeline_expands_to_contiguous_intervals() {
796        let scheme = scheme_one_ms1_two_ms2();
797        let tl = EventTimeline::from_scheme(&scheme).unwrap();
798        // 10 cycles * 3 events = 30 events.
799        assert_eq!(tl.events.len(), 30);
800        // Within a cycle the three events tile [0,1.2] back-to-back (0.4 each).
801        let first3 = &tl.events[..3];
802        assert!((first3[0].interval.0 - 0.0).abs() < 1e-9);
803        assert!((first3[2].interval.1 - 1.2).abs() < 1e-9);
804        for w in first3.windows(2) {
805            assert!((w[0].interval.1 - w[1].interval.0).abs() < 1e-9, "events must be contiguous");
806        }
807        // Second cycle starts at 1.2.
808        assert!((tl.events[3].interval.0 - 1.2).abs() < 1e-9);
809        // One MS1 per cycle.
810        assert_eq!(tl.ms1_intervals().len(), 10);
811    }
812
813    fn peptide(rt: f64, sigma: f64, lambda: f64) -> PeptideScalar {
814        use mscore::data::peptide::PeptideSequence;
815        PeptideScalar {
816            protein_id: 0,
817            peptide_id: 1,
818            sequence: PeptideSequence::new("PEPTIDEK".into(), Some(1)),
819            proteins: "P".into(),
820            decoy: false,
821            missed_cleavages: 0,
822            n_term: None,
823            c_term: None,
824            mono_isotopic_mass: 930.0,
825            retention_time: rt as f32,
826            rt_mu: rt,
827            rt_sigma: sigma,
828            rt_lambda: lambda,
829            events: 1.0,
830            condition_id: None,
831        }
832    }
833
834    #[test]
835    fn project_time_picks_events_near_rt() {
836        let scheme = scheme_one_ms1_two_ms2();
837        let tl = EventTimeline::from_scheme(&scheme).unwrap();
838        // Peptide eluting at ~6 s should map to MS1 events around cycle 5.
839        let pep = peptide(6.0, 0.3, 0.5);
840        let proj = project_time(&[pep], &tl, 0.9999, 0.01, 1);
841        assert_eq!(proj.len(), 1);
842        let hits = &proj[0];
843        // project_time now spans ALL events (MS1+MS2), returning global indices.
844        // RT-support truncation: touches some but NOT all events.
845        assert!(!hits.is_empty(), "peptide must touch some events");
846        assert!(hits.len() < tl.events.len(), "RT support must be truncated, not all events");
847        // Touched events are a contiguous global-index range with positive abundance.
848        for w in hits.windows(2) {
849            assert_eq!(w[1].0, w[0].0 + 1, "touched events must be contiguous in global index");
850        }
851        for (_, abund) in hits {
852            assert!(*abund > 0.0);
853        }
854    }
855
856    fn ion(ccs: f64, mz: f64, charge: i8, std: f64) -> IonScalar {
857        IonScalar {
858            ion_id: 1,
859            peptide_id: 1,
860            sequence: "PEPTIDEK".into(),
861            charge,
862            relative_abundance: 1.0,
863            mz,
864            ccs,
865            inv_mobility_std: std,
866            simulated_spectrum: MzSpectrum::new(vec![mz], vec![1.0]),
867            condition_id: None,
868        }
869    }
870
871    #[test]
872    fn timeline_mixed_durations_fill_remaining_cycle_time() {
873        // MS1 explicit 0.8 s; the two MS2 events split the remaining 0.4 s.
874        let mut scheme = scheme_one_ms1_two_ms2();
875        if let AcquisitionEvent::Ms1(ref mut m) = scheme.cycle[0] {
876            m.duration_s = Some(0.8);
877        }
878        let tl = EventTimeline::from_scheme(&scheme).unwrap();
879        let first3 = &tl.events[..3];
880        assert!((first3[0].interval.1 - first3[0].interval.0 - 0.8).abs() < 1e-9);
881        assert!((first3[1].interval.1 - first3[1].interval.0 - 0.2).abs() < 1e-9);
882        // Still tile to exactly one cycle (1.2 s).
883        assert!((first3[2].interval.1 - 1.2).abs() < 1e-9);
884    }
885
886    #[test]
887    fn timeline_rejects_durations_overrunning_cycle() {
888        let mut scheme = scheme_one_ms1_two_ms2();
889        if let AcquisitionEvent::Ms1(ref mut m) = scheme.cycle[0] {
890            m.duration_s = Some(2.0); // > cycle_time 1.2
891        }
892        assert!(EventTimeline::from_scheme(&scheme).is_err());
893    }
894
895    #[test]
896    fn timeline_honours_nonzero_start_time() {
897        let mut scheme = scheme_one_ms1_two_ms2();
898        scheme.repeat = RepeatPolicy::FixedCycleTime {
899            cycle_time_s: 1.2,
900            gradient_length_s: 12.0,
901            start_time_s: 3.0,
902        };
903        let tl = EventTimeline::from_scheme(&scheme).unwrap();
904        assert!((tl.events[0].interval.0 - 3.0).abs() < 1e-9);
905        // (12 - 3)/1.2 = 7 full cycles.
906        assert_eq!(tl.ms1_intervals().len(), 7);
907    }
908
909    #[test]
910    fn project_mobility_nonuniform_grid_uses_local_bins() {
911        // Non-uniform ascending grid: dense low, sparse high.
912        let mut grid = vec![0.60, 0.61, 0.62, 0.63, 0.64];
913        grid.extend([0.9, 1.2, 1.5]);
914        let geom = SamplingGeometry::tims(grid);
915        let env = MobilityEnv::default();
916        let i = ion(450.0, 600.0, 2, 0.05);
917        let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
918        // Output indices are ascending-grid positions, sorted, and in range.
919        assert!(!m.is_empty());
920        for w in m.windows(2) {
921            assert!(w[0].0 < w[1].0, "scan indices must be sorted ascending");
922        }
923        for (scan, a) in &m {
924            assert!((*scan as usize) < geom.num_scans());
925            assert!(*a >= 0.0);
926        }
927    }
928
929    #[test]
930    fn legacy_time_matches_raw_kernels() {
931        use mscore::algorithm::utility::{
932            calculate_frame_abundance_emg, calculate_frame_occurrence_emg,
933        };
934        // Contiguous 1..N frames table.
935        let n = 60usize;
936        let frame_ids: Vec<u32> = (1..=n as u32).collect();
937        let frame_times: Vec<f64> = (1..=n).map(|i| i as f64 * 0.1).collect();
938        let rt_cycle = 0.1;
939        // f64 inputs (LegacyCompat takes legacy-precision params directly).
940        let (mu, sigma, lambda) = (3.0_f64, 0.4_f64, 0.6_f64);
941        let out = project_time_legacy(
942            &[mu], &[sigma], &[lambda], &frame_ids, &frame_times, rt_cycle, 0.9999, 0.01, None, -1.0, 1,
943        );
944
945        // Raw kernel call with identical inputs + the same position->id mapping.
946        let positions = calculate_frame_occurrence_emg(&frame_times, mu, sigma, lambda, 0.9999, 0.01, None);
947        let occ_ids: Vec<i32> = positions.iter().map(|&p| frame_ids[(p - 1) as usize] as i32).collect();
948        let tmap: std::collections::HashMap<i32, f64> =
949            frame_ids.iter().zip(&frame_times).map(|(&id, &t)| (id as i32, t)).collect();
950        let abund = calculate_frame_abundance_emg(&tmap, &occ_ids, mu, sigma, lambda, rt_cycle, None);
951        let expected: Vec<(u32, f64)> = occ_ids.iter().map(|&id| id as u32).zip(abund).collect();
952        assert_eq!(out[0], expected, "LegacyCompat time must mirror the raw kernels");
953    }
954
955    #[test]
956    fn legacy_mobility_matches_raw_kernels() {
957        use mscore::algorithm::utility::{
958            calculate_abundance_gaussian, calculate_scan_occurrence_gaussian,
959        };
960        // Mobility ASCENDING (as the legacy job feeds it — im_cycle_length>0);
961        // scan ids align (high scan id = low mobility, so ids descend here).
962        let nn = 200usize;
963        let scan_ids: Vec<u32> = (0..nn as u32).rev().collect();
964        let scan_mob: Vec<f64> = (0..nn).map(|i| 0.804 + i as f64 * 0.004).collect();
965        let im_cycle = 0.004;
966        let env = MobilityEnv::default();
967        let i = ion(450.0, 600.0, 2, 0.02);
968        let mean = i.inv_mobility(&env);
969        let sigma = i.inv_mobility_std as f64;
970        let out = project_mobility_ion_legacy(mean, sigma, &scan_ids, &scan_mob, im_cycle, 0.9999, 0.0001);
971
972        let occ = calculate_scan_occurrence_gaussian(&scan_mob, mean, sigma, 0.9999, 0.0001, 5.0, 5.0);
973        let tmap: std::collections::HashMap<i32, f64> =
974            scan_ids.iter().zip(&scan_mob).map(|(&id, &m)| (id as i32, m)).collect();
975        let abund = calculate_abundance_gaussian(&tmap, &occ, mean, sigma, im_cycle);
976        let expected: Vec<(i32, f64)> = occ.into_iter().zip(abund).collect();
977        assert_eq!(out, expected, "LegacyCompat mobility must mirror the raw kernels");
978        assert!(!out.is_empty());
979    }
980
981    #[test]
982    fn timeline_rejects_explicit_durations_starving_unspecified_events() {
983        // MS1 duration == cycle_time leaves 0 for the two unspecified MS2 events.
984        let mut scheme = scheme_one_ms1_two_ms2();
985        if let AcquisitionEvent::Ms1(ref mut m) = scheme.cycle[0] {
986            m.duration_s = Some(1.2); // == cycle_time_s
987        }
988        assert!(EventTimeline::from_scheme(&scheme).is_err());
989    }
990
991    #[test]
992    fn project_mobility_zero_sigma_is_point_mass_not_nan() {
993        let grid: Vec<f64> = (0..100).map(|i| 1.005 + i as f64 * 0.005).collect();
994        let geom = SamplingGeometry::tims(grid);
995        let env = MobilityEnv::default();
996        let i = ion(450.0, 600.0, 2, 0.0); // zero std
997        let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
998        assert_eq!(m.len(), 1, "zero-sigma -> single point-mass scan");
999        assert!(m[0].1.is_finite() && (m[0].1 - 1.0).abs() < 1e-12);
1000        // Legacy path likewise.
1001        let scan_ids: Vec<u32> = (0..100).rev().collect();
1002        let scan_mob: Vec<f64> = (0..100).map(|i| 1.005 + i as f64 * 0.005).collect();
1003        let mean = i.inv_mobility(&env);
1004        let lm = project_mobility_ion_legacy(mean, 0.0, &scan_ids, &scan_mob, 0.005, 0.9999, 0.0001);
1005        assert_eq!(lm.len(), 1);
1006        assert!(lm[0].1.is_finite());
1007    }
1008
1009    #[test]
1010    fn project_mobility_none_marginalises_to_single_scan() {
1011        let geom = SamplingGeometry::none();
1012        let env = MobilityEnv::default();
1013        let i = ion(350.0, 500.0, 2, 0.01);
1014        let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
1015        assert_eq!(m, vec![(0, 1.0)]);
1016        assert_eq!(geom.num_scans(), 1);
1017    }
1018
1019    #[test]
1020    fn project_mobility_tims_lands_in_grid() {
1021        // Ascending 1/K0 grid (the kernel's input contract).
1022        let grid: Vec<f64> = (0..100).map(|i| 1.005 + i as f64 * 0.005).collect();
1023        let geom = SamplingGeometry::tims(grid);
1024        let env = MobilityEnv::default();
1025        // Pick an ion whose derived 1/K0 sits inside the grid.
1026        let i = ion(450.0, 600.0, 2, 0.02);
1027        let mean = i.inv_mobility(&env);
1028        assert!(mean > 0.6 && mean < 1.5, "test ion 1/K0 {mean} should fall in grid");
1029        let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
1030        assert!(!m.is_empty(), "ion must occupy >= 1 scan");
1031        let total: f64 = m.iter().map(|(_, a)| a).sum();
1032        assert!(total > 0.0);
1033    }
1034}