Skip to main content

rustdf/sim/
handle.rs

1use crate::sim::containers::{
2    FragmentIonSim, FrameToWindowGroupSim, FramesSim, IonScalar, IonSim, MobilityEnv,
3    PeptideScalar, PeptidesSim, ScansSim, SignalDistribution, WindowGroupSettingsSim,
4};
5use mscore::data::peptide::{FragmentType, PeptideProductIonSeriesCollection, PeptideSequence};
6use mscore::data::spectrum::{MsType, MzSpectrum};
7use mscore::simulation::annotation::MzSpectrumAnnotated;
8use mscore::timstof::collision::{TimsTofCollisionEnergy, TimsTofCollisionEnergyDIA};
9use mscore::timstof::quadrupole::{IonTransmission, PASEFMeta, TimsTransmissionDDA, TimsTransmissionDIA};
10use rayon::prelude::*;
11use rayon::ThreadPoolBuilder;
12use rusqlite::Connection;
13use std::collections::{BTreeMap, BTreeSet, HashSet};
14use std::path::Path;
15
16/// Resolve the fragment-map collision-energy key for an applied CE (eV),
17/// tolerant to ~0.1 eV quantization noise.
18///
19/// The map is keyed `round(stored_ce * 1e3)`, where `stored_ce` is the CE the
20/// predictor saw — quantized to 2 decimals (`ion_map_fn_*`: `round(ce*100)`) and
21/// persisted normalized as **f32**. The renderer only has the *applied* CE
22/// (DDA: full-precision `ce_bias + ce_slope*mobility`; DIA: the window CE). At
23/// ~0.1 eV key boundaries the stored f32 lands on the opposite side of the
24/// rounding boundary from any value reconstructed from the applied CE, so an
25/// exact key cannot be reproduced — the renderer used to silently drop those
26/// fragment series (DDA; DIA window CE is pre-rounded so it was unaffected).
27///
28/// Probe the natural key and its ±1 neighbours (±0.1 eV = the quantization-noise
29/// magnitude) and return the first present. A genuinely different CE (≫0.1 eV
30/// away — e.g. a prediction set built for another instrument) still resolves to
31/// `None`, which callers treat as a real miss. Returns `None` when no fragments
32/// exist near this CE for `(peptide, charge)`.
33pub fn resolve_fragment_ce_key<V>(
34    map: &BTreeMap<(u32, i8, i32), V>,
35    peptide_id: u32,
36    charge: i8,
37    applied_ce_ev: f64,
38) -> Option<i32> {
39    let base = (applied_ce_ev * 1e1).round() as i32;
40    [base, base - 1, base + 1]
41        .into_iter()
42        .find(|&k| map.contains_key(&(peptide_id, charge, k)))
43}
44
45/// Whether ANY collision-energy entry exists for `(peptide, charge)`. Used at
46/// render time to distinguish a legitimate "this precursor has no predicted
47/// fragments" skip (prefix absent) from a real "fragments exist but none near
48/// the applied CE" mismatch (prefix present) — the latter means the prediction
49/// set does not cover the instrument's applied CE and is a hard error.
50pub fn fragment_prefix_exists<V>(
51    map: &std::collections::BTreeMap<(u32, i8, i32), V>,
52    peptide_id: u32,
53    charge: i8,
54) -> bool {
55    map.range((peptide_id, charge, i32::MIN)..=(peptide_id, charge, i32::MAX))
56        .next()
57        .is_some()
58}
59
60/// Mean of consecutive differences of `xs` (the frame `rt_cycle_length` /
61/// `im_cycle_length` the legacy jobs derive via `np.mean(np.diff(...))`).
62fn mean_consecutive_diff(xs: &[f64]) -> f64 {
63    if xs.len() < 2 {
64        return 0.0;
65    }
66    let total: f64 = xs.windows(2).map(|w| w[1] - w[0]).sum();
67    total / (xs.len() - 1) as f64
68}
69
70/// How the stored `fragment_ions` were produced (P5 prediction set). The
71/// renderer reads this to verify the stored fragments are compatible with the
72/// instrument it is rendering for, instead of silently rendering against a set
73/// built for a different instrument / collision-energy encoding.
74#[derive(Debug, Clone)]
75pub struct PredictionSet {
76    pub prediction_set_id: i64,
77    pub predictor_model: Option<String>,
78    pub instrument: String,
79    pub acquisition_type: String,
80    pub activation_method: String,
81    pub energy_unit: String,
82    /// How collision energy is encoded in `fragment_ions` — the render-time CE
83    /// keying (`resolve_fragment_ce_key`) only matches the legacy
84    /// `normalized_div100` encoding.
85    pub collision_energy_encoding: String,
86}
87
88impl PredictionSet {
89    /// The implicit set for pre-P5 DBs (no `prediction_sets` table): timsTOF
90    /// collisional activation, CE stored normalized (raw/100), eV.
91    pub fn legacy_bruker() -> Self {
92        PredictionSet {
93            prediction_set_id: 0,
94            predictor_model: None,
95            instrument: "bruker_timstof".to_string(),
96            acquisition_type: "unknown".to_string(),
97            activation_method: "hcd".to_string(),
98            energy_unit: "ev".to_string(),
99            collision_energy_encoding: "normalized_div100".to_string(),
100        }
101    }
102
103    /// Fail unless this set's collision-energy encoding is the one the render-time
104    /// CE keying assumes. Guards against rendering fragments that were stored with
105    /// an encoding the current keying cannot resolve (e.g. a future Thermo set).
106    pub fn assert_render_compatible(&self) -> Result<(), String> {
107        if self.collision_energy_encoding != "normalized_div100" {
108            return Err(format!(
109                "prediction set {} has collision_energy_encoding='{}' but the \
110                 renderer expects 'normalized_div100'; the stored fragments were \
111                 built for a different instrument/encoding (model={:?}, instrument={})",
112                self.prediction_set_id,
113                self.collision_energy_encoding,
114                self.predictor_model,
115                self.instrument,
116            ));
117        }
118        Ok(())
119    }
120}
121
122#[derive(Debug)]
123pub struct TimsTofSyntheticsDataHandle {
124    pub connection: Connection,
125}
126
127impl TimsTofSyntheticsDataHandle {
128    pub fn new(path: &Path) -> rusqlite::Result<Self> {
129        let connection = Connection::open(path)?;
130        Ok(Self { connection })
131    }
132
133    pub fn read_frames(&self) -> rusqlite::Result<Vec<FramesSim>> {
134        let mut stmt = self.connection.prepare(
135            "SELECT frame_id, time, ms_type FROM frames"
136        )?;
137        let frames_iter = stmt.query_map([], |row| {
138            Ok(FramesSim::new(
139                row.get("frame_id")?,
140                row.get("time")?,
141                row.get("ms_type")?,
142            ))
143        })?;
144        let mut frames = Vec::new();
145        for frame in frames_iter {
146            frames.push(frame?);
147        }
148        Ok(frames)
149    }
150
151    pub fn read_scans(&self) -> rusqlite::Result<Vec<ScansSim>> {
152        let mut stmt = self.connection.prepare(
153            "SELECT scan, mobility FROM scans ORDER BY scan"
154        )?;
155        let scans_iter = stmt.query_map([], |row| {
156            Ok(ScansSim::new(
157                row.get("scan")?,
158                row.get("mobility")?,
159            ))
160        })?;
161        let mut scans = Vec::new();
162        for scan in scans_iter {
163            scans.push(scan?);
164        }
165        Ok(scans)
166    }
167
168    pub fn read_peptides(&self) -> rusqlite::Result<Vec<PeptidesSim>> {
169        let mut stmt = self.connection.prepare("SELECT * FROM peptides ORDER BY peptide_id")?;
170        let peptides_iter = stmt.query_map([], |row| {
171            Self::peptide_from_row(row)
172        })?;
173        let mut peptides = Vec::new();
174        for peptide in peptides_iter {
175            peptides.push(peptide?);
176        }
177        Ok(peptides)
178    }
179
180    /// Parse a single peptide row by column name (order-independent).
181    fn peptide_from_row(row: &rusqlite::Row) -> rusqlite::Result<PeptidesSim> {
182        let frame_occurrence_str: String = row.get("frame_occurrence")?;
183        let frame_abundance_str: String = row.get("frame_abundance")?;
184
185        let frame_occurrence: Vec<u32> = serde_json::from_str(&frame_occurrence_str)
186            .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
187                0, rusqlite::types::Type::Text, Box::new(e),
188            ))?;
189
190        let frame_abundance: Vec<f32> = match serde_json::from_str(&frame_abundance_str) {
191            Ok(value) => value,
192            Err(_) => vec![0.0; frame_occurrence.len()],
193        };
194
195        let frame_distribution =
196            SignalDistribution::new(0.0, 0.0, 0.0, frame_occurrence, frame_abundance);
197
198        let peptide_id: u32 = row.get("peptide_id")?;
199
200        Ok(PeptidesSim {
201            protein_id: row.get("protein_id")?,
202            peptide_id,
203            sequence: PeptideSequence::new(row.get("sequence")?, Some(peptide_id as i32)),
204            proteins: row.get("protein")?,
205            decoy: row.get("decoy")?,
206            missed_cleavages: row.get("missed_cleavages")?,
207            n_term: row.get("n_term")?,
208            c_term: row.get("c_term")?,
209            mono_isotopic_mass: row.get("monoisotopic-mass")?,
210            retention_time: row.get("retention_time_gru_predictor")?,
211            events: row.get("events")?,
212            frame_start: row.get("frame_occurrence_start")?,
213            frame_end: row.get("frame_occurrence_end")?,
214            frame_distribution,
215        })
216    }
217
218    /// Parse a single ion row by column name (order-independent).
219    fn ion_from_row(row: &rusqlite::Row) -> rusqlite::Result<IonSim> {
220        let simulated_spectrum_str: String = row.get("simulated_spectrum")?;
221        let scan_occurrence_str: String = row.get("scan_occurrence")?;
222        let scan_abundance_str: String = row.get("scan_abundance")?;
223
224        let simulated_spectrum: MzSpectrum = serde_json::from_str(&simulated_spectrum_str)
225            .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
226                0, rusqlite::types::Type::Text, Box::new(e),
227            ))?;
228
229        let scan_occurrence: Vec<u32> = serde_json::from_str(&scan_occurrence_str)
230            .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
231                0, rusqlite::types::Type::Text, Box::new(e),
232            ))?;
233
234        let scan_abundance: Vec<f32> = serde_json::from_str(&scan_abundance_str)
235            .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
236                0, rusqlite::types::Type::Text, Box::new(e),
237            ))?;
238
239        Ok(IonSim::new(
240            row.get("ion_id")?,
241            row.get("peptide_id")?,
242            row.get("sequence")?,
243            row.get("charge")?,
244            row.get("relative_abundance")?,
245            row.get("inv_mobility_gru_predictor")?,
246            simulated_spectrum,
247            scan_occurrence,
248            scan_abundance,
249        ))
250    }
251
252    /// Parse a single fragment ion row by column name (order-independent).
253    fn fragment_ion_from_row(row: &rusqlite::Row) -> rusqlite::Result<FragmentIonSim> {
254        let indices_string: String = row.get("indices")?;
255        let values_string: String = row.get("values")?;
256
257        let indices: Vec<u32> = serde_json::from_str(&indices_string)
258            .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
259                0, rusqlite::types::Type::Text, Box::new(e),
260            ))?;
261
262        let values: Vec<f64> = serde_json::from_str(&values_string)
263            .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
264                0, rusqlite::types::Type::Text, Box::new(e),
265            ))?;
266
267        Ok(FragmentIonSim::new(
268            row.get("peptide_id")?,
269            row.get("ion_id")?,
270            row.get("collision_energy")?,
271            row.get("charge")?,
272            indices,
273            values,
274        ))
275    }
276
277    pub fn read_ions(&self) -> rusqlite::Result<Vec<IonSim>> {
278        let mut stmt = self.connection.prepare("SELECT * FROM ions ORDER BY ion_id")?;
279        let ions_iter = stmt.query_map([], |row| Self::ion_from_row(row))?;
280        let mut ions = Vec::new();
281        for ion in ions_iter {
282            ions.push(ion?);
283        }
284        Ok(ions)
285    }
286
287    // ----------------------------------------------------------------------- //
288    // Instrument-dispatch P1: parallel scalar-native readers.
289    //
290    // These read ONLY the trunk scalar physics — no JSON occurrence/abundance
291    // vectors — and are additive (the legacy read_peptides/read_ions above are
292    // untouched). For a legacy DB that persisted 1/K0 (and no `ccs` column),
293    // CCS is derived under the supplied `MobilityEnv`.
294    // ----------------------------------------------------------------------- //
295
296    /// Reject anything that isn't a bare SQL identifier (PRAGMA can't be
297    /// parameterised, so the table name is interpolated — guard it even though
298    /// all internal callers pass literals).
299    fn assert_ident(name: &str) -> rusqlite::Result<()> {
300        let ok = !name.is_empty()
301            && name.chars().next().map_or(false, |c| c.is_ascii_alphabetic() || c == '_')
302            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
303        if ok {
304            Ok(())
305        } else {
306            Err(rusqlite::Error::InvalidParameterName(format!(
307                "unsafe SQL identifier: {name:?}"
308            )))
309        }
310    }
311
312    /// True if `table` exists and has `column` (used to stay forward/backward
313    /// compatible across schema versions without per-row failures).
314    fn table_has_column(&self, table: &str, column: &str) -> rusqlite::Result<bool> {
315        Self::assert_ident(table)?;
316        let mut stmt = self
317            .connection
318            .prepare(&format!("PRAGMA table_info({})", table))?;
319        let mut found = false;
320        let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
321        for name in rows {
322            if name? == column {
323                found = true;
324            }
325        }
326        Ok(found)
327    }
328
329    /// Read the run's mobility environment from `experiment_conditions`, falling
330    /// back to timsTOF defaults when the table/columns are absent (pre-P1 DB).
331    /// Requires all three env columns; a partially-migrated table falls back to
332    /// defaults rather than erroring on a missing column.
333    pub fn read_mobility_env(&self) -> rusqlite::Result<MobilityEnv> {
334        let complete = self.table_has_column("experiment_conditions", "drift_gas_mass")?
335            && self.table_has_column("experiment_conditions", "temperature_c")?
336            && self.table_has_column("experiment_conditions", "t_diff")?;
337        if !complete {
338            return Ok(MobilityEnv::default());
339        }
340        let mut stmt = self.connection.prepare(
341            "SELECT drift_gas_mass, temperature_c, t_diff FROM experiment_conditions LIMIT 1",
342        )?;
343        let mut rows = stmt.query_map([], |row| {
344            Ok(MobilityEnv {
345                gas_mass: row.get(0)?,
346                temp_c: row.get(1)?,
347                t_diff: row.get(2)?,
348            })
349        })?;
350        match rows.next() {
351            Some(env) => env,
352            None => Ok(MobilityEnv::default()),
353        }
354    }
355
356    /// Read peptides as scalar-native trunk entities (no frame-occurrence vectors).
357    fn peptide_scalar_from_row(
358        row: &rusqlite::Row,
359        has_condition: bool,
360        has_rt_mu: bool,
361    ) -> rusqlite::Result<PeptideScalar> {
362        let peptide_id: u32 = row.get("peptide_id")?;
363        let condition_id = if has_condition {
364            row.get::<_, Option<i64>>("condition_id")?
365        } else {
366            None
367        };
368        let retention_time: f32 = row.get("retention_time_gru_predictor")?;
369        // The EMG location is rt_mu (derived from the apex); fall back to the
370        // apex itself only when the column is absent (pre-distribution DB). Read
371        // as f64 (DB REAL) for byte-faithful projection.
372        let rt_mu: f64 = if has_rt_mu { row.get("rt_mu")? } else { retention_time as f64 };
373        Ok(PeptideScalar {
374            protein_id: row.get("protein_id")?,
375            peptide_id,
376            sequence: PeptideSequence::new(row.get("sequence")?, Some(peptide_id as i32)),
377            proteins: row.get("protein")?,
378            decoy: row.get("decoy")?,
379            missed_cleavages: row.get("missed_cleavages")?,
380            n_term: row.get("n_term")?,
381            c_term: row.get("c_term")?,
382            mono_isotopic_mass: row.get("monoisotopic-mass")?,
383            retention_time,
384            rt_mu,
385            rt_sigma: row.get("rt_sigma")?,
386            rt_lambda: row.get("rt_lambda")?,
387            events: row.get("events")?,
388            condition_id,
389        })
390    }
391
392    fn ion_scalar_from_row(
393        row: &rusqlite::Row,
394        env: &MobilityEnv,
395        has_ccs: bool,
396        has_condition: bool,
397        has_inv_std: bool,
398    ) -> rusqlite::Result<IonScalar> {
399        let simulated_spectrum_str: String = row.get("simulated_spectrum")?;
400        let simulated_spectrum: MzSpectrum = serde_json::from_str(&simulated_spectrum_str)
401            .map_err(|e| {
402                rusqlite::Error::FromSqlConversionFailure(
403                    0,
404                    rusqlite::types::Type::Text,
405                    Box::new(e),
406                )
407            })?;
408        let charge: i8 = row.get("charge")?;
409        let mz: f64 = row.get("mz")?;
410        let ccs: f64 = if has_ccs {
411            row.get("ccs")?
412        } else {
413            let one_over_k0: f64 = row.get("inv_mobility_gru_predictor")?;
414            env.ccs_from_inv_mobility(one_over_k0, mz, charge)
415        };
416        // Probe presence explicitly; propagate real conversion errors
417        // (only a genuinely absent column defaults to 0.0).
418        let inv_mobility_std: f64 = if has_inv_std {
419            row.get("inv_mobility_gru_predictor_std")?
420        } else {
421            0.0
422        };
423        let condition_id = if has_condition {
424            row.get::<_, Option<i64>>("condition_id")?
425        } else {
426            None
427        };
428        Ok(IonScalar {
429            ion_id: row.get("ion_id")?,
430            peptide_id: row.get("peptide_id")?,
431            sequence: row.get("sequence")?,
432            charge,
433            relative_abundance: row.get("relative_abundance")?,
434            mz,
435            ccs,
436            inv_mobility_std,
437            simulated_spectrum,
438            condition_id,
439        })
440    }
441
442    pub fn read_peptides_scalar(&self) -> rusqlite::Result<Vec<PeptideScalar>> {
443        let has_condition = self.table_has_column("peptides", "condition_id")?;
444        let has_rt_mu = self.table_has_column("peptides", "rt_mu")?;
445        let mut stmt = self.connection.prepare("SELECT * FROM peptides ORDER BY peptide_id")?;
446        let iter = stmt.query_map([], |row| {
447            Self::peptide_scalar_from_row(row, has_condition, has_rt_mu)
448        })?;
449        let mut out = Vec::new();
450        for p in iter {
451            out.push(p?);
452        }
453        Ok(out)
454    }
455
456    /// Read scalar peptides for a specific set of ids (chunked `IN` query), so
457    /// the lazy projector path loads only the batch's candidates instead of the
458    /// whole table. Order matches `read_peptides_scalar` (ORDER BY peptide_id).
459    pub fn read_peptides_scalar_for_ids(
460        &self,
461        peptide_ids: &[u32],
462    ) -> rusqlite::Result<Vec<PeptideScalar>> {
463        if peptide_ids.is_empty() {
464            return Ok(Vec::new());
465        }
466        let has_condition = self.table_has_column("peptides", "condition_id")?;
467        let has_rt_mu = self.table_has_column("peptides", "rt_mu")?;
468        const CHUNK_SIZE: usize = 500;
469        let mut out = Vec::new();
470        for chunk in peptide_ids.chunks(CHUNK_SIZE) {
471            let placeholders: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
472            let sql = format!(
473                "SELECT * FROM peptides WHERE peptide_id IN ({}) ORDER BY peptide_id",
474                placeholders
475            );
476            let mut stmt = self.connection.prepare(&sql)?;
477            let iter = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
478                Self::peptide_scalar_from_row(row, has_condition, has_rt_mu)
479            })?;
480            for p in iter {
481                out.push(p?);
482            }
483        }
484        Ok(out)
485    }
486
487    /// Read ions as scalar-native trunk entities (no scan-occurrence vectors).
488    /// CCS is read directly if a `ccs` column exists, otherwise derived from the
489    /// legacy `inv_mobility_gru_predictor` (1/K0) under `env`.
490    pub fn read_ions_scalar(&self, env: &MobilityEnv) -> rusqlite::Result<Vec<IonScalar>> {
491        let has_ccs = self.table_has_column("ions", "ccs")?;
492        let has_condition = self.table_has_column("ions", "condition_id")?;
493        let has_inv_std = self.table_has_column("ions", "inv_mobility_gru_predictor_std")?;
494        let mut stmt = self.connection.prepare("SELECT * FROM ions ORDER BY ion_id")?;
495        let env = *env;
496        let iter = stmt.query_map([], move |row| {
497            Self::ion_scalar_from_row(row, &env, has_ccs, has_condition, has_inv_std)
498        })?;
499        let mut out = Vec::new();
500        for i in iter {
501            out.push(i?);
502        }
503        Ok(out)
504    }
505
506    /// Read scalar ions for a specific set of peptide ids (chunked `IN` query).
507    /// The lazy projector path uses this so it does NOT load + JSON-deserialize
508    /// every ion's simulated spectrum per batch (that would restore eager-scale
509    /// memory). Within a peptide, ions are ordered by ion_id (chunking is by
510    /// peptide_id, so a peptide's ions all live in one chunk).
511    pub fn read_ions_scalar_for_peptides(
512        &self,
513        peptide_ids: &[u32],
514        env: &MobilityEnv,
515    ) -> rusqlite::Result<Vec<IonScalar>> {
516        if peptide_ids.is_empty() {
517            return Ok(Vec::new());
518        }
519        let has_ccs = self.table_has_column("ions", "ccs")?;
520        let has_condition = self.table_has_column("ions", "condition_id")?;
521        let has_inv_std = self.table_has_column("ions", "inv_mobility_gru_predictor_std")?;
522        let env = *env;
523        const CHUNK_SIZE: usize = 500;
524        let mut out = Vec::new();
525        for chunk in peptide_ids.chunks(CHUNK_SIZE) {
526            let placeholders: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
527            let sql = format!(
528                "SELECT * FROM ions WHERE peptide_id IN ({}) ORDER BY peptide_id, ion_id",
529                placeholders
530            );
531            let mut stmt = self.connection.prepare(&sql)?;
532            let iter = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), move |row| {
533                Self::ion_scalar_from_row(row, &env, has_ccs, has_condition, has_inv_std)
534            })?;
535            for i in iter {
536                out.push(i?);
537            }
538        }
539        Ok(out)
540    }
541
542    // ----------------------------------------------------------------------- //
543    // Instrument-dispatch P4 (canonical builder state): source-aware entity
544    // reads. Produce the SAME PeptidesSim/IonSim the column path produces, but
545    // with the occurrence/abundance distributions taken from either the legacy
546    // JSON columns (`Columns`) or the render-time projector (`Projector`). Every
547    // downstream consumer reads the same entity fields, so nothing else changes.
548    // ----------------------------------------------------------------------- //
549
550    /// Read peptides with their frame distribution from `source`.
551    pub fn read_peptides_with_source(
552        &self,
553        source: &crate::sim::projector::DistributionSource,
554    ) -> rusqlite::Result<Vec<PeptidesSim>> {
555        use crate::sim::projector::DistributionSource;
556        let (mode, params) = match source {
557            DistributionSource::Columns => return self.read_peptides(),
558            DistributionSource::Projector { mode, params, .. } => (*mode, *params),
559        };
560        let scalars = self.read_peptides_scalar()?;
561        self.project_peptide_scalars(scalars, mode, params)
562    }
563
564    /// Convert a projected abundance to the stored `f32`, applying LegacyCompat's
565    /// bit-compatibility rounding. The legacy / `project_distributions` writer
566    /// stores abundances as `float(np.round(x, num_decimals))`
567    /// (utility.python_list_to_json_string), AFTER the remove_epsilon threshold.
568    /// LegacyCompat reproduces that exact stored value (configurable precision +
569    /// NumPy's round-half-to-even) so projector-fed rendering byte-matches
570    /// column-fed rendering; Accurate keeps full precision.
571    fn abundance_for_mode(
572        a: f64,
573        mode: crate::sim::projector::ProjectionMode,
574        num_decimals: u32,
575    ) -> f32 {
576        match mode {
577            crate::sim::projector::ProjectionMode::LegacyCompat => {
578                let scale = 10f64.powi(num_decimals as i32);
579                // round_ties_even matches np.round (banker's rounding); f64::round
580                // rounds half away from zero and would break byte-compat on exact
581                // half-way abundances.
582                ((a * scale).round_ties_even() / scale) as f32
583            }
584            crate::sim::projector::ProjectionMode::Accurate => a as f32,
585        }
586    }
587
588    /// Project a (possibly filtered) set of scalar peptides into `PeptidesSim`
589    /// with projector-filled frame distributions. Shared by the eager
590    /// (full-table) reader and the lazy per-batch reader so there is one
591    /// projection implementation. Projection is per-peptide independent, so a
592    /// filtered subset yields identical entities to projecting all then
593    /// filtering.
594    fn project_peptide_scalars(
595        &self,
596        scalars: Vec<PeptideScalar>,
597        mode: crate::sim::projector::ProjectionMode,
598        params: crate::sim::projector::ProjectionParams,
599    ) -> rusqlite::Result<Vec<PeptidesSim>> {
600        use crate::sim::projector::ProjectionMode;
601        // Frames table, ascending by id, as the projector expects.
602        let mut frames = self.read_frames()?;
603        frames.sort_by_key(|f| f.frame_id);
604        let frame_ids: Vec<u32> = frames.iter().map(|f| f.frame_id).collect();
605        let frame_times: Vec<f64> = frames.iter().map(|f| f.time as f64).collect();
606        if frame_times.len() < 2 {
607            return Err(rusqlite::Error::InvalidQuery);
608        }
609        let rt_cycle = mean_consecutive_diff(&frame_times);
610        let mus: Vec<f64> = scalars.iter().map(|p| p.rt_mu).collect();
611        let sigmas: Vec<f64> = scalars.iter().map(|p| p.rt_sigma).collect();
612        let lambdas: Vec<f64> = scalars.iter().map(|p| p.rt_lambda).collect();
613
614        let projected: Vec<Vec<(u32, f64)>> = match mode {
615            ProjectionMode::LegacyCompat => crate::sim::projector::project_time_legacy(
616                &mus, &sigmas, &lambdas, &frame_ids, &frame_times, rt_cycle, params.target_p,
617                params.frame_step_size, params.n_steps, params.remove_epsilon, params.num_threads,
618            ),
619            ProjectionMode::Accurate => {
620                // Accurate: integrate each frame's true [prev, this] exposure
621                // interval; map event index -> frame id; apply remove_epsilon.
622                let mut starts = Vec::with_capacity(frame_times.len());
623                let mut ends = Vec::with_capacity(frame_times.len());
624                for (i, &t) in frame_times.iter().enumerate() {
625                    let prev = if i > 0 { frame_times[i - 1] } else { t - (frame_times[1] - frame_times[0]) };
626                    starts.push(prev);
627                    ends.push(t);
628                }
629                let intervals: Vec<(f64, f64)> = starts.into_iter().zip(ends).collect();
630                let proj = mscore::algorithm::utility::project_emg_over_events_par(
631                    &intervals, mus.clone(), sigmas.clone(), lambdas.clone(), params.target_p,
632                    params.frame_step_size, params.num_threads.max(1), params.n_steps,
633                );
634                proj.into_iter()
635                    .map(|pairs| {
636                        pairs.into_iter()
637                            .filter(|(_, a)| *a > params.remove_epsilon)
638                            .map(|(i, a)| (frame_ids[i], a))
639                            .collect()
640                    })
641                    .collect()
642            }
643        };
644
645        Ok(scalars
646            .into_iter()
647            .zip(projected)
648            .map(|(p, pairs)| {
649                let occ: Vec<u32> = pairs.iter().map(|(f, _)| *f).collect();
650                let ab: Vec<f32> = pairs.iter().map(|(_, a)| Self::abundance_for_mode(*a, mode, params.num_decimals)).collect();
651                let frame_start = occ.first().copied().unwrap_or(0);
652                let frame_end = occ.last().copied().unwrap_or(0);
653                PeptidesSim::new(
654                    p.protein_id, p.peptide_id, p.sequence.sequence.clone(), p.proteins, p.decoy,
655                    p.missed_cleavages, p.n_term, p.c_term, p.mono_isotopic_mass, p.retention_time,
656                    p.events, frame_start, frame_end, occ, ab,
657                )
658            })
659            .collect())
660    }
661
662    /// Read ions with their scan distribution from `source`.
663    pub fn read_ions_with_source(
664        &self,
665        source: &crate::sim::projector::DistributionSource,
666    ) -> rusqlite::Result<Vec<IonSim>> {
667        use crate::sim::projector::DistributionSource;
668        let (mode, env, params) = match source {
669            DistributionSource::Columns => return self.read_ions(),
670            DistributionSource::Projector { mode, env, params } => (*mode, *env, *params),
671        };
672        let scalars = self.read_ions_scalar(&env)?;
673        self.project_ion_scalars(scalars, mode, env, params)
674    }
675
676    /// Project a (possibly filtered) set of scalar ions into `IonSim` with
677    /// projector-filled scan distributions. Shared by the eager (full-table)
678    /// reader and the lazy per-batch reader. Per-ion independent, so a filtered
679    /// subset yields identical entities.
680    fn project_ion_scalars(
681        &self,
682        scalars: Vec<IonScalar>,
683        mode: crate::sim::projector::ProjectionMode,
684        env: MobilityEnv,
685        params: crate::sim::projector::ProjectionParams,
686    ) -> rusqlite::Result<Vec<IonSim>> {
687        use crate::sim::projector::ProjectionMode;
688        // Scans ascending by mobility (im_cycle_length > 0), as the projector expects.
689        let mut scans = self.read_scans()?;
690        scans.sort_by(|a, b| a.mobility.partial_cmp(&b.mobility).unwrap_or(std::cmp::Ordering::Equal));
691        let scan_ids: Vec<u32> = scans.iter().map(|s| s.scan).collect();
692        let scan_mob: Vec<f64> = scans.iter().map(|s| s.mobility as f64).collect();
693        if scan_mob.len() < 2 {
694            return Err(rusqlite::Error::InvalidQuery);
695        }
696        let im_cycle = mean_consecutive_diff(&scan_mob);
697        let means: Vec<f64> = scalars.iter().map(|i| i.inv_mobility(&env)).collect();
698        let sigmas: Vec<f64> = scalars.iter().map(|i| i.inv_mobility_std).collect();
699
700        let projected: Vec<Vec<(i32, f64)>> = match mode {
701            ProjectionMode::LegacyCompat => crate::sim::projector::project_mobility_legacy_par(
702                &means, &sigmas, &scan_ids, &scan_mob, im_cycle, params.target_p,
703                params.scan_step_size, params.num_threads,
704            ),
705            ProjectionMode::Accurate => {
706                // Accurate per-scan midpoint bins; ascending-grid index -> scan id.
707                let acc = crate::sim::projector::project_mobility_accurate_par(
708                    &means, &sigmas, &scan_mob, params.target_p, params.scan_step_size,
709                    params.num_threads,
710                );
711                acc.into_iter()
712                    .map(|pairs| pairs.into_iter().map(|(idx, a)| (scan_ids[idx as usize] as i32, a)).collect())
713                    .collect()
714            }
715        };
716
717        Ok(scalars
718            .into_iter()
719            .zip(projected)
720            .map(|(ion, pairs)| {
721                let occ: Vec<u32> = pairs.iter().map(|(s, _)| *s as u32).collect();
722                let ab: Vec<f32> = pairs.iter().map(|(_, a)| Self::abundance_for_mode(*a, mode, params.num_decimals)).collect();
723                let mobility = ion.inv_mobility(&env) as f32;
724                IonSim::new(
725                    ion.ion_id, ion.peptide_id, ion.sequence, ion.charge, ion.relative_abundance,
726                    mobility, ion.simulated_spectrum, occ, ab,
727                )
728            })
729            .collect())
730    }
731
732    /// Read the fragment prediction set (P5). Returns the single registered set,
733    /// or `PredictionSet::legacy_bruker()` for pre-P5 DBs that have no
734    /// `prediction_sets` table (so existing data keeps rendering as the implicit
735    /// Bruker set). Distinguishes "verified provenance" from "assumed legacy" by
736    /// virtue of the table's presence.
737    pub fn read_prediction_set(&self) -> rusqlite::Result<PredictionSet> {
738        let has_table: bool = self
739            .connection
740            .query_row(
741                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='prediction_sets'",
742                [],
743                |_| Ok(true),
744            )
745            .unwrap_or(false);
746        if !has_table {
747            return Ok(PredictionSet::legacy_bruker());
748        }
749        self.connection.query_row(
750            "SELECT prediction_set_id, predictor_model, instrument, acquisition_type, \
751             activation_method, energy_unit, collision_energy_encoding \
752             FROM prediction_sets ORDER BY prediction_set_id LIMIT 1",
753            [],
754            |row| {
755                Ok(PredictionSet {
756                    prediction_set_id: row.get(0)?,
757                    predictor_model: row.get(1)?,
758                    instrument: row.get(2)?,
759                    acquisition_type: row.get(3)?,
760                    activation_method: row.get(4)?,
761                    energy_unit: row.get(5)?,
762                    collision_energy_encoding: row.get(6)?,
763                })
764            },
765        )
766    }
767
768    /// Candidate peptide ids overlapping a frame range, by the legacy occurrence
769    /// columns. P4a2 uses this as the lazy candidate index for BOTH the columns
770    /// and projector paths; P4d replaces it with a scalar RT-support index that
771    /// does not depend on the occurrence columns. For LegacyCompat this is exact;
772    /// for Accurate it is the same conservative window the columns already define.
773    fn candidate_peptide_ids_for_frame_range(
774        &self,
775        frame_min: u32,
776        frame_max: u32,
777    ) -> rusqlite::Result<Vec<u32>> {
778        let mut stmt = self.connection.prepare(
779            "SELECT peptide_id FROM peptides WHERE frame_occurrence_start <= ?1 AND frame_occurrence_end >= ?2 ORDER BY peptide_id",
780        )?;
781        let iter = stmt.query_map([frame_max, frame_min], |row| row.get::<_, u32>(0))?;
782        let mut out = Vec::new();
783        for id in iter {
784            out.push(id?);
785        }
786        Ok(out)
787    }
788
789    /// Lazy per-batch peptide read, source-aware. `Columns` returns the legacy
790    /// column-fed `read_peptides_for_frame_range`; `Projector` selects candidates
791    /// for the range, reads their scalars, and projects (same entities the eager
792    /// projector reader produces, restricted to the batch).
793    pub fn read_peptides_for_frame_range_with_source(
794        &self,
795        frame_min: u32,
796        frame_max: u32,
797        source: &crate::sim::projector::DistributionSource,
798    ) -> rusqlite::Result<Vec<PeptidesSim>> {
799        use crate::sim::projector::DistributionSource;
800        let (mode, params) = match source {
801            DistributionSource::Columns => {
802                return self.read_peptides_for_frame_range(frame_min, frame_max)
803            }
804            DistributionSource::Projector { mode, params, .. } => (*mode, *params),
805        };
806        // Candidate selection by the legacy occurrence-column range query. Exact
807        // for LegacyCompat (the projector reproduces the column kernel); P4d
808        // replaces this with a scalar RT-support index that (a) does not depend
809        // on the occurrence columns and (b) conservatively covers Accurate
810        // support that may extend beyond the legacy stored range.
811        //
812        // Until P4d lands that index, lazy + Accurate would silently OMIT
813        // peptides whose accurate RT support extends past the stored occurrence
814        // window at the batch boundary. The eager Accurate path (read_peptides_
815        // with_source) has no such window and is complete; production wires lazy
816        // to Columns only. Fail loudly here rather than truncate the signal: the
817        // connector exposes lazy+Accurate directly, and silent omission is worse
818        // than an explicit "not yet supported".
819        if matches!(mode, crate::sim::projector::ProjectionMode::Accurate) {
820            return Err(rusqlite::Error::InvalidParameterName(
821                "lazy loading with projection_mode='accurate' is not yet supported \
822                 (the per-batch candidate index uses legacy occurrence columns and \
823                 would omit peptides whose accurate RT support crosses a batch \
824                 boundary); use eager loading for accurate projection, or \
825                 projection_mode='legacy_compat' with lazy loading"
826                    .to_string(),
827            ));
828        }
829        let candidate_ids = self.candidate_peptide_ids_for_frame_range(frame_min, frame_max)?;
830        // Load + project only the batch's candidate scalars (filtered in SQL).
831        let scalars = self.read_peptides_scalar_for_ids(&candidate_ids)?;
832        self.project_peptide_scalars(scalars, mode, params)
833    }
834
835    /// Lazy per-batch ion read, source-aware. `Columns` returns the legacy
836    /// `read_ions_for_peptides`; `Projector` reads the scalars for these peptides
837    /// and projects their scan distributions.
838    pub fn read_ions_for_peptides_with_source(
839        &self,
840        peptide_ids: &[u32],
841        source: &crate::sim::projector::DistributionSource,
842    ) -> rusqlite::Result<Vec<IonSim>> {
843        use crate::sim::projector::DistributionSource;
844        let (mode, env, params) = match source {
845            DistributionSource::Columns => return self.read_ions_for_peptides(peptide_ids),
846            DistributionSource::Projector { mode, env, params } => (*mode, *env, *params),
847        };
848        // Filter ions to the requested peptides in SQL — avoids loading and
849        // JSON-deserializing every ion's simulated spectrum per batch.
850        let scalars = self.read_ions_scalar_for_peptides(peptide_ids, &env)?;
851        self.project_ion_scalars(scalars, mode, env, params)
852    }
853
854    pub fn read_window_group_settings(&self) -> rusqlite::Result<Vec<WindowGroupSettingsSim>> {
855        let mut stmt = self.connection.prepare("SELECT * FROM dia_ms_ms_windows")?;
856        let window_group_settings_iter = stmt.query_map([], |row| {
857            Ok(WindowGroupSettingsSim::new(
858                row.get("window_group")?,
859                row.get("scan_start")?,
860                row.get("scan_end")?,
861                row.get("isolation_mz")?,
862                row.get("isolation_width")?,
863                row.get("collision_energy")?,
864            ))
865        })?;
866        let mut window_group_settings = Vec::new();
867        for window_group_setting in window_group_settings_iter {
868            window_group_settings.push(window_group_setting?);
869        }
870        Ok(window_group_settings)
871    }
872
873    pub fn read_frame_to_window_group(&self) -> rusqlite::Result<Vec<FrameToWindowGroupSim>> {
874        let mut stmt = self.connection.prepare("SELECT * FROM dia_ms_ms_info")?;
875        let frame_to_window_group_iter = stmt.query_map([], |row| {
876            Ok(FrameToWindowGroupSim::new(
877                row.get("frame")?,
878                row.get("window_group")?,
879            ))
880        })?;
881
882        let mut frame_to_window_groups: Vec<FrameToWindowGroupSim> = Vec::new();
883        for frame_to_window_group in frame_to_window_group_iter {
884            frame_to_window_groups.push(frame_to_window_group?);
885        }
886
887        Ok(frame_to_window_groups)
888    }
889
890    pub fn read_pasef_meta(&self) -> rusqlite::Result<Vec<PASEFMeta>> {
891        let mut stmt = self.connection.prepare(
892            "SELECT frame, scan_start, scan_end, isolation_mz, isolation_width, collision_energy, precursor FROM pasef_meta"
893        )?;
894        let pasef_meta_iter = stmt.query_map([], |row| {
895            Ok(PASEFMeta::new(
896                row.get("frame")?,
897                row.get("scan_start")?,
898                row.get("scan_end")?,
899                row.get("isolation_mz")?,
900                row.get("isolation_width")?,
901                row.get("collision_energy")?,
902                row.get("precursor")?,
903            ))
904        })?;
905
906        let mut pasef_meta: Vec<PASEFMeta> = Vec::new();
907
908        for pasef_meta_entry in pasef_meta_iter {
909            pasef_meta.push(pasef_meta_entry?);
910        }
911
912        Ok(pasef_meta)
913    }
914
915    /// Read peptides that are present in the given frame range.
916    /// A peptide is included if its frame range overlaps with [frame_min, frame_max].
917    pub fn read_peptides_for_frame_range(
918        &self,
919        frame_min: u32,
920        frame_max: u32,
921    ) -> rusqlite::Result<Vec<PeptidesSim>> {
922        let mut stmt = self.connection.prepare(
923            "SELECT * FROM peptides WHERE frame_occurrence_start <= ?1 AND frame_occurrence_end >= ?2 ORDER BY peptide_id"
924        )?;
925
926        let peptides_iter = stmt.query_map([frame_max, frame_min], |row| {
927            Self::peptide_from_row(row)
928        })?;
929
930        let mut peptides = Vec::new();
931        for peptide in peptides_iter {
932            peptides.push(peptide?);
933        }
934        Ok(peptides)
935    }
936
937    /// Read ions for specific peptide IDs.
938    /// Uses batched queries for efficiency with large peptide ID lists.
939    pub fn read_ions_for_peptides(&self, peptide_ids: &[u32]) -> rusqlite::Result<Vec<IonSim>> {
940        if peptide_ids.is_empty() {
941            return Ok(Vec::new());
942        }
943
944        let mut all_ions = Vec::new();
945
946        // Process in chunks to avoid SQLite parameter limits
947        const CHUNK_SIZE: usize = 500;
948
949        for chunk in peptide_ids.chunks(CHUNK_SIZE) {
950            let placeholders: String = chunk.iter()
951                .map(|_| "?")
952                .collect::<Vec<_>>()
953                .join(",");
954
955            // ORDER BY peptide_id, ion_id matches the eager full-table read
956            // (`read_ions`). Ion order within a peptide is load-bearing: the DDA
957            // fragment builder selects an ion via `charges.position(charge)`, so a
958            // different order can fragment a different ion. Chunking is by
959            // peptide_id, so each peptide's ions live in one chunk and are fully
960            // ordered here.
961            let sql = format!(
962                "SELECT * FROM ions WHERE peptide_id IN ({}) ORDER BY peptide_id, ion_id",
963                placeholders
964            );
965
966            let mut stmt = self.connection.prepare(&sql)?;
967
968            let ions_iter = stmt.query_map(
969                rusqlite::params_from_iter(chunk.iter()),
970                |row| Self::ion_from_row(row),
971            )?;
972
973            for ion in ions_iter {
974                all_ions.push(ion?);
975            }
976        }
977
978        Ok(all_ions)
979    }
980
981    /// Read fragment ions for specific peptide IDs.
982    /// Uses batched queries for efficiency with large peptide ID lists.
983    pub fn read_fragment_ions_for_peptides(&self, peptide_ids: &[u32]) -> rusqlite::Result<Vec<FragmentIonSim>> {
984        if peptide_ids.is_empty() {
985            return Ok(Vec::new());
986        }
987
988        let mut all_fragment_ions = Vec::new();
989
990        // Process in chunks to avoid SQLite parameter limits
991        const CHUNK_SIZE: usize = 500;
992
993        for chunk in peptide_ids.chunks(CHUNK_SIZE) {
994            let placeholders: String = chunk.iter()
995                .map(|_| "?")
996                .collect::<Vec<_>>()
997                .join(",");
998
999            let sql = format!(
1000                "SELECT * FROM fragment_ions WHERE peptide_id IN ({}) ORDER BY peptide_id",
1001                placeholders
1002            );
1003
1004            let mut stmt = self.connection.prepare(&sql)?;
1005
1006            let fragment_ion_iter = stmt.query_map(
1007                rusqlite::params_from_iter(chunk.iter()),
1008                |row| Self::fragment_ion_from_row(row),
1009            )?;
1010
1011            for fragment_ion in fragment_ion_iter {
1012                all_fragment_ions.push(fragment_ion?);
1013            }
1014        }
1015
1016        Ok(all_fragment_ions)
1017    }
1018
1019    pub fn read_fragment_ions(&self) -> rusqlite::Result<Vec<FragmentIonSim>> {
1020        let mut stmt = self.connection.prepare("SELECT * FROM fragment_ions")?;
1021
1022        let fragment_ion_sim_iter = stmt.query_map([], |row| {
1023            Self::fragment_ion_from_row(row)
1024        })?;
1025
1026        let mut fragment_ion_sim = Vec::new();
1027        for fragment_ion in fragment_ion_sim_iter {
1028            fragment_ion_sim.push(fragment_ion?);
1029        }
1030
1031        Ok(fragment_ion_sim)
1032    }
1033
1034    pub fn get_transmission_dia(&self) -> TimsTransmissionDIA {
1035        let frame_to_window_group = self.read_frame_to_window_group().unwrap();
1036        let window_group_settings = self.read_window_group_settings().unwrap();
1037
1038        TimsTransmissionDIA::new(
1039            frame_to_window_group
1040                .iter()
1041                .map(|x| x.frame_id as i32)
1042                .collect(),
1043            frame_to_window_group
1044                .iter()
1045                .map(|x| x.window_group as i32)
1046                .collect(),
1047            window_group_settings
1048                .iter()
1049                .map(|x| x.window_group as i32)
1050                .collect(),
1051            window_group_settings
1052                .iter()
1053                .map(|x| x.scan_start as i32)
1054                .collect(),
1055            window_group_settings
1056                .iter()
1057                .map(|x| x.scan_end as i32)
1058                .collect(),
1059            window_group_settings
1060                .iter()
1061                .map(|x| x.isolation_mz as f64)
1062                .collect(),
1063            window_group_settings
1064                .iter()
1065                .map(|x| x.isolation_width as f64)
1066                .collect(),
1067            None,
1068        )
1069    }
1070
1071    pub fn get_transmission_dda(&self) -> TimsTransmissionDDA {
1072        let pasef_meta = self.read_pasef_meta().unwrap();
1073        TimsTransmissionDDA::new(
1074            pasef_meta,
1075            None,
1076        )
1077    }
1078
1079    pub fn get_collision_energy_dia(&self) -> TimsTofCollisionEnergyDIA {
1080        let frame_to_window_group = self.read_frame_to_window_group().unwrap();
1081        let window_group_settings = self.read_window_group_settings().unwrap();
1082
1083        TimsTofCollisionEnergyDIA::new(
1084            frame_to_window_group
1085                .iter()
1086                .map(|x| x.frame_id as i32)
1087                .collect(),
1088            frame_to_window_group
1089                .iter()
1090                .map(|x| x.window_group as i32)
1091                .collect(),
1092            window_group_settings
1093                .iter()
1094                .map(|x| x.window_group as i32)
1095                .collect(),
1096            window_group_settings
1097                .iter()
1098                .map(|x| x.scan_start as i32)
1099                .collect(),
1100            window_group_settings
1101                .iter()
1102                .map(|x| x.scan_end as i32)
1103                .collect(),
1104            window_group_settings
1105                .iter()
1106                .map(|x| x.collision_energy as f64)
1107                .collect(),
1108        )
1109    }
1110
1111    fn ion_map_fn_dda(
1112        ion: IonSim,
1113        peptide_map: &BTreeMap<u32, PeptidesSim>,
1114        _precursor_frames: &HashSet<u32>,
1115        transmission: &TimsTransmissionDDA,
1116    ) -> BTreeSet<(u32, u32, String, i8, i32)> {
1117        let peptide = peptide_map.get(&ion.peptide_id).unwrap();
1118        let mut ret_tree: BTreeSet<(u32, u32, String, i8, i32)> = BTreeSet::new();
1119
1120        // Get all frames where this ion was explicitly selected for fragmentation
1121        // using the precursor ID from pasef_meta (instead of re-computing m/z transmission)
1122        let selections = transmission.get_selections_for_precursor(ion.ion_id as i32);
1123
1124        for (_, collision_energy) in selections {
1125            // Quantize with same factor as DIA (line 614) so return conversion (line 702)
1126            // divides by 100 to recover raw CE, matching the Python normalization flow
1127            let quantized_energy = (collision_energy * 100.0).round() as i32;
1128
1129            ret_tree.insert((
1130                ion.peptide_id,
1131                ion.ion_id,
1132                peptide.sequence.sequence.clone(),
1133                ion.charge,
1134                quantized_energy,
1135            ));
1136        }
1137
1138        ret_tree
1139    }
1140
1141    fn ion_map_fn_dia(
1142        ion: IonSim,
1143        peptide_map: &BTreeMap<u32, PeptidesSim>,
1144        precursor_frames: &HashSet<u32>,
1145        transmission: &TimsTransmissionDIA,
1146        collision_energy: &TimsTofCollisionEnergyDIA,
1147    ) -> BTreeSet<(u32, u32, String, i8, i32)> {
1148        let peptide = peptide_map.get(&ion.peptide_id).unwrap();
1149        let mut ret_tree: BTreeSet<(u32, u32, String, i8, i32)> = BTreeSet::new();
1150
1151        // go over all frames the ion occurs in
1152        for frame in peptide.frame_distribution.occurrence.iter() {
1153            // only consider fragment frames
1154            if !precursor_frames.contains(frame) {
1155                // go over all scans the ion occurs in
1156                for scan in &ion.scan_distribution.occurrence {
1157                    // check transmission for all precursor ion peaks of the isotopic envelope
1158
1159                    let precursor_spec = &ion.simulated_spectrum;
1160
1161                    if transmission.any_transmitted(
1162                        *frame as i32,
1163                        *scan as i32,
1164                        &precursor_spec.mz,
1165                        Some(0.5),
1166                    ) {
1167                        let collision_energy =
1168                            collision_energy.get_collision_energy(*frame as i32, *scan as i32);
1169                        let quantized_energy = (collision_energy * 100.0).round() as i32;
1170
1171                        ret_tree.insert((
1172                            ion.peptide_id,
1173                            ion.ion_id,
1174                            peptide.sequence.sequence.clone(),
1175                            ion.charge,
1176                            quantized_energy,
1177                        ));
1178                    }
1179                }
1180            }
1181        }
1182        ret_tree
1183    }
1184
1185    // TODO: take isotopic envelope into account
1186    pub fn get_transmitted_ions(
1187        &self,
1188        num_threads: usize,
1189        dda_mode: bool,
1190    ) -> (Vec<i32>, Vec<i32>, Vec<String>, Vec<i8>, Vec<f32>) {
1191
1192        let thread_pool = ThreadPoolBuilder::new()
1193            .num_threads(num_threads)
1194            .build()
1195            .unwrap();
1196
1197        let peptides = self.read_peptides().unwrap();
1198
1199        let peptide_map = TimsTofSyntheticsDataHandle::build_peptide_map(&peptides);
1200
1201        let precursor_frames =
1202            TimsTofSyntheticsDataHandle::build_precursor_frame_id_set(&self.read_frames().unwrap());
1203
1204        let ions = self.read_ions().unwrap();
1205
1206        let trees = match dda_mode {
1207            true => {
1208                let transmission = self.get_transmission_dda();
1209                thread_pool.install(|| {
1210                    ions.par_iter()
1211                        .map(|ion| {
1212                            TimsTofSyntheticsDataHandle::ion_map_fn_dda(
1213                                ion.clone(),
1214                                &peptide_map,
1215                                &precursor_frames,
1216                                &transmission,
1217                            )
1218                        })
1219                        .collect::<Vec<_>>()
1220            })
1221        },
1222            false => {
1223                let transmission = self.get_transmission_dia();
1224                let collision_energy = self.get_collision_energy_dia();
1225                thread_pool.install(|| {
1226                    ions.par_iter()
1227                        .map(|ion| {
1228                            TimsTofSyntheticsDataHandle::ion_map_fn_dia(
1229                                ion.clone(),
1230                                &peptide_map,
1231                                &precursor_frames,
1232                                &transmission,
1233                                &collision_energy,
1234                            )
1235                        })
1236                        .collect::<Vec<_>>()
1237                })
1238            },
1239        };
1240
1241        let mut ret_tree: BTreeSet<(u32, u32, String, i8, i32)> = BTreeSet::new();
1242        for tree in trees {
1243            ret_tree.extend(tree);
1244        }
1245
1246        let mut ret_peptide_id = Vec::new();
1247        let mut ret_ion_id = Vec::new();
1248        let mut ret_sequence = Vec::new();
1249        let mut ret_charge = Vec::new();
1250        let mut ret_energy = Vec::new();
1251
1252        for (peptide_id, ion_id, sequence, charge, energy) in ret_tree {
1253            ret_peptide_id.push(peptide_id as i32);
1254            ret_ion_id.push(ion_id as i32);
1255            ret_sequence.push(sequence);
1256            ret_charge.push(charge);
1257            ret_energy.push(energy as f32 / 100.0);
1258        }
1259
1260        (
1261            ret_peptide_id,
1262            ret_ion_id,
1263            ret_sequence,
1264            ret_charge,
1265            ret_energy,
1266        )
1267    }
1268
1269    /// Lazy version of get_transmitted_ions that only loads data for a specific frame range.
1270    /// This reduces memory usage by only loading peptides and ions that are relevant to the
1271    /// specified frame range instead of all data from the database.
1272    ///
1273    /// # Arguments
1274    ///
1275    /// * `frame_min` - Minimum frame ID to include (inclusive)
1276    /// * `frame_max` - Maximum frame ID to include (inclusive)
1277    /// * `num_threads` - Number of threads to use for parallel processing
1278    /// * `dda_mode` - If true, use DDA transmission; if false, use DIA transmission
1279    ///
1280    /// # Returns
1281    ///
1282    /// Tuple of (peptide_ids, ion_ids, sequences, charges, collision_energies) for transmitted ions
1283    pub fn get_transmitted_ions_for_frame_range(
1284        &self,
1285        frame_min: u32,
1286        frame_max: u32,
1287        num_threads: usize,
1288        dda_mode: bool,
1289    ) -> (Vec<i32>, Vec<i32>, Vec<String>, Vec<i8>, Vec<f32>) {
1290
1291        let thread_pool = ThreadPoolBuilder::new()
1292            .num_threads(num_threads)
1293            .build()
1294            .unwrap();
1295
1296        // Only load peptides for the specified frame range
1297        let peptides = self.read_peptides_for_frame_range(frame_min, frame_max).unwrap();
1298
1299        if peptides.is_empty() {
1300            return (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
1301        }
1302
1303        let peptide_ids: Vec<u32> = peptides.iter().map(|p| p.peptide_id).collect();
1304        let peptide_map = TimsTofSyntheticsDataHandle::build_peptide_map(&peptides);
1305
1306        let precursor_frames =
1307            TimsTofSyntheticsDataHandle::build_precursor_frame_id_set(&self.read_frames().unwrap());
1308
1309        // Only load ions for the peptides in our frame range
1310        let ions = self.read_ions_for_peptides(&peptide_ids).unwrap();
1311
1312        if ions.is_empty() {
1313            return (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
1314        }
1315
1316        let trees = match dda_mode {
1317            true => {
1318                let transmission = self.get_transmission_dda();
1319                thread_pool.install(|| {
1320                    ions.par_iter()
1321                        .map(|ion| {
1322                            TimsTofSyntheticsDataHandle::ion_map_fn_dda(
1323                                ion.clone(),
1324                                &peptide_map,
1325                                &precursor_frames,
1326                                &transmission,
1327                            )
1328                        })
1329                        .collect::<Vec<_>>()
1330                })
1331            },
1332            false => {
1333                let transmission = self.get_transmission_dia();
1334                let collision_energy = self.get_collision_energy_dia();
1335                thread_pool.install(|| {
1336                    ions.par_iter()
1337                        .map(|ion| {
1338                            TimsTofSyntheticsDataHandle::ion_map_fn_dia(
1339                                ion.clone(),
1340                                &peptide_map,
1341                                &precursor_frames,
1342                                &transmission,
1343                                &collision_energy,
1344                            )
1345                        })
1346                        .collect::<Vec<_>>()
1347                })
1348            },
1349        };
1350
1351        let mut ret_tree: BTreeSet<(u32, u32, String, i8, i32)> = BTreeSet::new();
1352        for tree in trees {
1353            ret_tree.extend(tree);
1354        }
1355
1356        let mut ret_peptide_id = Vec::new();
1357        let mut ret_ion_id = Vec::new();
1358        let mut ret_sequence = Vec::new();
1359        let mut ret_charge = Vec::new();
1360        let mut ret_energy = Vec::new();
1361
1362        for (peptide_id, ion_id, sequence, charge, energy) in ret_tree {
1363            ret_peptide_id.push(peptide_id as i32);
1364            ret_ion_id.push(ion_id as i32);
1365            ret_sequence.push(sequence);
1366            ret_charge.push(charge);
1367            ret_energy.push(energy as f32 / 100.0);
1368        }
1369
1370        (
1371            ret_peptide_id,
1372            ret_ion_id,
1373            ret_sequence,
1374            ret_charge,
1375            ret_energy,
1376        )
1377    }
1378
1379    /// Method to build a map from peptide id to ions
1380    pub fn build_peptide_to_ion_map(ions: &Vec<IonSim>) -> BTreeMap<u32, Vec<IonSim>> {
1381        let mut ion_map = BTreeMap::new();
1382        for ion in ions.iter() {
1383            let ions = ion_map.entry(ion.peptide_id).or_insert_with(Vec::new);
1384            ions.push(ion.clone());
1385        }
1386        ion_map
1387    }
1388
1389    /// Method to build a map from peptide id to events (absolute number of events in the simulation)
1390    pub fn build_peptide_map(peptides: &Vec<PeptidesSim>) -> BTreeMap<u32, PeptidesSim> {
1391        let mut peptide_map = BTreeMap::new();
1392        for peptide in peptides.iter() {
1393            peptide_map.insert(peptide.peptide_id, peptide.clone());
1394        }
1395        peptide_map
1396    }
1397
1398    /// Method to build a set of precursor frame ids, can be used to check if a frame is a precursor frame
1399    pub fn build_precursor_frame_id_set(frames: &Vec<FramesSim>) -> HashSet<u32> {
1400        frames
1401            .iter()
1402            .filter(|frame| frame.parse_ms_type() == MsType::Precursor)
1403            .map(|frame| frame.frame_id)
1404            .collect()
1405    }
1406
1407    // Method to build a map from peptide id to events (absolute number of events in the simulation)
1408    pub fn build_peptide_to_events(peptides: &Vec<PeptidesSim>) -> BTreeMap<u32, f32> {
1409        let mut peptide_to_events = BTreeMap::new();
1410        for peptide in peptides.iter() {
1411            peptide_to_events.insert(peptide.peptide_id, peptide.events);
1412        }
1413        peptide_to_events
1414    }
1415
1416    // Method to build a map from frame id to retention time
1417    pub fn build_frame_to_rt(frames: &Vec<FramesSim>) -> BTreeMap<u32, f32> {
1418        let mut frame_to_rt = BTreeMap::new();
1419        for frame in frames.iter() {
1420            frame_to_rt.insert(frame.frame_id, frame.time);
1421        }
1422        frame_to_rt
1423    }
1424
1425    // Method to build a map from scan id to mobility
1426    pub fn build_scan_to_mobility(scans: &Vec<ScansSim>) -> BTreeMap<u32, f32> {
1427        let mut scan_to_mobility = BTreeMap::new();
1428        for scan in scans.iter() {
1429            scan_to_mobility.insert(scan.scan, scan.mobility);
1430        }
1431        scan_to_mobility
1432    }
1433    pub fn build_frame_to_abundances(
1434        peptides: &Vec<PeptidesSim>,
1435    ) -> BTreeMap<u32, (Vec<u32>, Vec<f32>)> {
1436        let mut frame_to_abundances = BTreeMap::new();
1437
1438        for peptide in peptides.iter() {
1439            let peptide_id = peptide.peptide_id;
1440            // Borrow the per-peptide occurrence/abundance vectors instead of cloning
1441            // them — the loop only copies the scalar peptide_id (u32) and abundance
1442            // (f32) into the map, so the clones were pure transient garbage (~300M
1443            // elements copied at 150K peptides). Output is identical.
1444            for (frame_id, abundance) in peptide
1445                .frame_distribution
1446                .occurrence
1447                .iter()
1448                .zip(peptide.frame_distribution.abundance.iter())
1449            {
1450                // only insert if the abundance is greater than 1e-6
1451                if *abundance > 1e-6 {
1452                    let (occurrences, abundances) = frame_to_abundances
1453                        .entry(*frame_id)
1454                        .or_insert((vec![], vec![]));
1455                    occurrences.push(peptide_id);
1456                    abundances.push(*abundance);
1457                }
1458            }
1459        }
1460
1461        frame_to_abundances
1462    }
1463    pub fn build_peptide_to_ions(
1464        ions: &Vec<IonSim>,
1465    ) -> BTreeMap<
1466        u32,
1467        (
1468            Vec<f32>,
1469            Vec<Vec<u32>>,
1470            Vec<Vec<f32>>,
1471            Vec<i8>,
1472            Vec<MzSpectrum>,
1473        ),
1474    > {
1475        let mut peptide_to_ions = BTreeMap::new();
1476
1477        for ion in ions.iter() {
1478            let peptide_id = ion.peptide_id;
1479            let abundance = ion.relative_abundance;
1480            let scan_occurrence = ion.scan_distribution.occurrence.clone();
1481            let scan_abundance = ion.scan_distribution.abundance.clone();
1482            let charge = ion.charge;
1483            let spectrum = ion.simulated_spectrum.clone();
1484
1485            let (abundances, scan_occurrences, scan_abundances, charges, spectra) = peptide_to_ions
1486                .entry(peptide_id)
1487                .or_insert((vec![], vec![], vec![], vec![], vec![]));
1488            abundances.push(abundance);
1489            scan_occurrences.push(scan_occurrence);
1490            scan_abundances.push(scan_abundance);
1491            charges.push(charge);
1492            spectra.push(spectrum);
1493        }
1494
1495        peptide_to_ions
1496    }
1497    pub fn build_fragment_ions(
1498        peptides_sim: &BTreeMap<u32, PeptidesSim>,
1499        fragment_ions: &Vec<FragmentIonSim>,
1500        num_threads: usize,
1501    ) -> BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrum>)> {
1502        let thread_pool = ThreadPoolBuilder::new()
1503            .num_threads(num_threads)
1504            .build()
1505            .unwrap();
1506        let fragment_ion_map = thread_pool.install(|| {
1507            fragment_ions
1508                .par_iter()
1509                .map(|fragment_ion| {
1510                    let key = (
1511                        fragment_ion.peptide_id,
1512                        fragment_ion.charge,
1513                        (fragment_ion.collision_energy * 1e3).round() as i32,
1514                    );
1515
1516                    let value = peptides_sim
1517                        .get(&fragment_ion.peptide_id)
1518                        .unwrap()
1519                        .sequence
1520                        .associate_with_predicted_intensities(
1521                            fragment_ion.charge as i32,
1522                            FragmentType::B,
1523                            fragment_ion.to_dense(174),
1524                            true,
1525                            true,
1526                        );
1527
1528                    let fragment_ions: Vec<MzSpectrum> = value
1529                        .peptide_ions
1530                        .par_iter()
1531                        .map(|ion_series| {
1532                            ion_series.generate_isotopic_spectrum(1e-2, 1e-3, 100, 1e-5)
1533                        })
1534                        .collect();
1535                    (key, (value, fragment_ions))
1536                })
1537                .collect::<BTreeMap<_, _>>() // Collect the results into a BTreeMap
1538        });
1539
1540        fragment_ion_map
1541    }
1542
1543    pub fn build_fragment_ions_annotated(
1544        peptides_sim: &BTreeMap<u32, PeptidesSim>,
1545        fragment_ions: &Vec<FragmentIonSim>,
1546        num_threads: usize,
1547    ) -> BTreeMap<(u32, i8, i32), (PeptideProductIonSeriesCollection, Vec<MzSpectrumAnnotated>)>
1548    {
1549        let thread_pool = ThreadPoolBuilder::new()
1550            .num_threads(num_threads)
1551            .build()
1552            .unwrap();
1553        let fragment_ion_map = thread_pool.install(|| {
1554            fragment_ions
1555                .par_iter()
1556                .map(|fragment_ion| {
1557                    let key = (
1558                        fragment_ion.peptide_id,
1559                        fragment_ion.charge,
1560                        (fragment_ion.collision_energy * 1e3).round() as i32,
1561                    );
1562
1563                    let value = peptides_sim
1564                        .get(&fragment_ion.peptide_id)
1565                        .unwrap()
1566                        .sequence
1567                        .associate_with_predicted_intensities(
1568                            fragment_ion.charge as i32,
1569                            FragmentType::B,
1570                            fragment_ion.to_dense(174),
1571                            true,
1572                            true,
1573                        );
1574
1575                    let fragment_ions: Vec<MzSpectrumAnnotated> = value
1576                        .peptide_ions
1577                        .par_iter()
1578                        .map(|ion_series| {
1579                            ion_series.generate_isotopic_spectrum_annotated(1e-2, 1e-3, 100, 1e-5)
1580                        })
1581                        .collect();
1582                    (key, (value, fragment_ions))
1583                })
1584                .collect::<BTreeMap<_, _>>() // Collect the results into a BTreeMap
1585        });
1586
1587        fragment_ion_map
1588    }
1589
1590    /// Build fragment ions with complementary isotope distribution data.
1591    ///
1592    /// This variant calculates both the fragment isotope distribution and
1593    /// the complementary fragment isotope distribution, which are needed
1594    /// for quad-selection dependent isotope transmission calculations.
1595    ///
1596    /// # Arguments
1597    ///
1598    /// * `peptides_sim` - Map of peptide_id to PeptidesSim
1599    /// * `fragment_ions` - Vector of FragmentIonSim
1600    /// * `num_threads` - Number of threads for parallel processing
1601    ///
1602    /// # Returns
1603    ///
1604    /// * `BTreeMap` mapping (peptide_id, charge, collision_energy) to
1605    ///   (PeptideProductIonSeriesCollection, fragment spectra, fragment distributions, complementary distributions)
1606    /// Build fragment ions with transmission data for both precursor scaling and per-fragment modes.
1607    ///
1608    /// This function calculates:
1609    /// - Precursor isotope distribution (for PrecursorScaling mode)
1610    /// - Per-fragment isotope distributions with their complementary distributions (for PerFragment mode)
1611    pub fn build_fragment_ions_with_transmission_data(
1612        peptides_sim: &BTreeMap<u32, PeptidesSim>,
1613        fragment_ions: &Vec<FragmentIonSim>,
1614        num_threads: usize,
1615    ) -> BTreeMap<(u32, i8, i32), FragmentIonsWithComplementary> {
1616        let thread_pool = ThreadPoolBuilder::new()
1617            .num_threads(num_threads)
1618            .build()
1619            .unwrap();
1620
1621        let fragment_ion_map = thread_pool.install(|| {
1622            fragment_ions
1623                .par_iter()
1624                .map(|fragment_ion| {
1625                    let key = (
1626                        fragment_ion.peptide_id,
1627                        fragment_ion.charge,
1628                        (fragment_ion.collision_energy * 1e3).round() as i32,
1629                    );
1630
1631                    let peptide_sim = peptides_sim.get(&fragment_ion.peptide_id).unwrap();
1632
1633                    // Get precursor atomic composition for complementary calculations
1634                    let precursor_composition = peptide_sim.sequence.atomic_composition();
1635
1636                    // Calculate precursor isotope distribution for PrecursorScaling mode
1637                    let precursor_composition_owned: std::collections::HashMap<String, i32> =
1638                        precursor_composition.iter().map(|(k, v)| (k.to_string(), *v)).collect();
1639                    let precursor_isotope_distribution = mscore::algorithm::isotope::generate_isotope_distribution(
1640                        &precursor_composition_owned,
1641                        1e-3,
1642                        1e-8,
1643                        100,
1644                    ).into_iter().filter(|&(_, abundance)| abundance > 1e-10).collect();
1645
1646                    let ion_series_collection = peptide_sim
1647                        .sequence
1648                        .associate_with_predicted_intensities(
1649                            fragment_ion.charge as i32,
1650                            FragmentType::B,
1651                            fragment_ion.to_dense(174),
1652                            true,
1653                            true,
1654                        );
1655
1656                    // Calculate fragment spectra and per-fragment transmission data
1657                    let mut fragment_spectra: Vec<MzSpectrum> = Vec::new();
1658                    let mut per_fragment_data: Vec<Vec<FragmentIonTransmissionData>> = Vec::new();
1659
1660                    for ion_series in &ion_series_collection.peptide_ions {
1661                        // Generate the full isotopic spectrum for this ion series
1662                        let spectrum = ion_series.generate_isotopic_spectrum(1e-2, 1e-3, 100, 1e-5);
1663                        fragment_spectra.push(spectrum);
1664
1665                        // Build per-fragment data for this series
1666                        let mut series_fragment_data: Vec<FragmentIonTransmissionData> = Vec::new();
1667
1668                        // Process n-terminal ions (b-ions)
1669                        for n_ion in &ion_series.n_ions {
1670                            let frag_dist = n_ion.isotope_distribution(1e-3, 1e-8, 100, 1e-10);
1671                            let comp_dist = n_ion.complementary_isotope_distribution(
1672                                &precursor_composition,
1673                                1e-3,
1674                                1e-8,
1675                                100,
1676                            );
1677
1678                            series_fragment_data.push(FragmentIonTransmissionData {
1679                                fragment_distribution: frag_dist,
1680                                complementary_distribution: comp_dist,
1681                                predicted_intensity: n_ion.ion.intensity,
1682                            });
1683                        }
1684
1685                        // Process c-terminal ions (y-ions)
1686                        for c_ion in &ion_series.c_ions {
1687                            let frag_dist = c_ion.isotope_distribution(1e-3, 1e-8, 100, 1e-10);
1688                            let comp_dist = c_ion.complementary_isotope_distribution(
1689                                &precursor_composition,
1690                                1e-3,
1691                                1e-8,
1692                                100,
1693                            );
1694
1695                            series_fragment_data.push(FragmentIonTransmissionData {
1696                                fragment_distribution: frag_dist,
1697                                complementary_distribution: comp_dist,
1698                                predicted_intensity: c_ion.ion.intensity,
1699                            });
1700                        }
1701
1702                        per_fragment_data.push(series_fragment_data);
1703                    }
1704
1705                    let data = FragmentIonsWithComplementary {
1706                        ion_series_collection,
1707                        fragment_spectra,
1708                        precursor_isotope_distribution,
1709                        per_fragment_data,
1710                    };
1711
1712                    (key, data)
1713                })
1714                .collect::<BTreeMap<_, _>>()
1715        });
1716
1717        fragment_ion_map
1718    }
1719}
1720
1721/// Data for a single fragment ion with its complementary distribution.
1722#[derive(Debug, Clone)]
1723pub struct FragmentIonTransmissionData {
1724    /// Fragment isotope distribution as (m/z, abundance) pairs
1725    pub fragment_distribution: Vec<(f64, f64)>,
1726    /// Complementary fragment isotope distribution as (mass, abundance) pairs
1727    pub complementary_distribution: Vec<(f64, f64)>,
1728    /// Predicted intensity of this fragment ion
1729    pub predicted_intensity: f64,
1730}
1731
1732/// Struct holding fragment ion data along with transmission calculation data.
1733///
1734/// This is used for quad-selection dependent isotope transmission calculations,
1735/// where fragment isotope patterns are adjusted based on which precursor isotopes
1736/// were transmitted through the quadrupole.
1737#[derive(Debug, Clone)]
1738pub struct FragmentIonsWithComplementary {
1739    /// The original ion series collection with intensity predictions
1740    pub ion_series_collection: PeptideProductIonSeriesCollection,
1741    /// Pre-calculated fragment spectra (standard isotope patterns)
1742    pub fragment_spectra: Vec<MzSpectrum>,
1743    /// Precursor isotope distribution for scaling mode (m/z, abundance)
1744    pub precursor_isotope_distribution: Vec<(f64, f64)>,
1745    /// Per-fragment transmission data for per-fragment mode
1746    /// Outer Vec: one per ion_series, Inner Vec: one per fragment ion in that series
1747    pub per_fragment_data: Vec<Vec<FragmentIonTransmissionData>>,
1748}
1749
1750#[cfg(test)]
1751mod scalar_reader_tests {
1752    use super::*;
1753    use rusqlite::Connection;
1754
1755    #[test]
1756    fn prediction_set_compatibility() {
1757        // Legacy / Bruker set: render-compatible.
1758        assert!(PredictionSet::legacy_bruker().assert_render_compatible().is_ok());
1759
1760        // P6d: an Orbitrap Astral NCE set is ALSO render-compatible — the stored CE
1761        // is CE/100 regardless of unit, so the encoding stays normalized_div100 and
1762        // the render keying (round(applied*10) == round(stored*1000)) is
1763        // unit-agnostic. Only the unit LABEL differs (provenance), not the keying.
1764        let mut astral = PredictionSet::legacy_bruker();
1765        astral.instrument = "orbitrap_astral".to_string();
1766        astral.energy_unit = "nce".to_string();
1767        astral.predictor_model = Some("prosit_hcd".to_string());
1768        // encoding intentionally left as normalized_div100
1769        assert!(astral.assert_render_compatible().is_ok());
1770
1771        // A set whose CE ENCODING the keying cannot resolve (not the unit — the
1772        // encoding) is rejected rather than silently mis-keyed.
1773        let mut bad_encoding = PredictionSet::legacy_bruker();
1774        bad_encoding.collision_energy_encoding = "nce_raw".to_string();
1775        assert!(bad_encoding.assert_render_compatible().is_err());
1776    }
1777
1778    #[test]
1779    fn resolve_fragment_ce_key_tolerates_quantization_noise() {
1780        // Map keyed at the stored 0.1-eV resolution (round(stored*1e3) ~ raw*10).
1781        let mut map: BTreeMap<(u32, i8, i32), ()> = BTreeMap::new();
1782        map.insert((1, 2, 330), ()); // stored key for ~33.0 eV
1783
1784        // Exact natural key (raw 33.0 -> 330) resolves.
1785        assert_eq!(resolve_fragment_ce_key(&map, 1, 2, 33.00), Some(330));
1786        // Raw 33.0499 -> natural key 331, but stored is 330: the ±1 probe finds it.
1787        assert_eq!(resolve_fragment_ce_key(&map, 1, 2, 33.0499), Some(330));
1788        // Raw 32.95 -> natural key 330 (rounds up) — direct hit.
1789        assert_eq!(resolve_fragment_ce_key(&map, 1, 2, 32.95), Some(330));
1790        // A genuinely different CE (~35 eV, key ~350) is >1 away -> real miss.
1791        assert_eq!(resolve_fragment_ce_key(&map, 1, 2, 35.0), None);
1792        // Different (peptide, charge) -> miss.
1793        assert_eq!(resolve_fragment_ce_key(&map, 9, 2, 33.0), None);
1794    }
1795
1796    /// Build a minimal legacy-shaped synthetic_data.db (1/K0 only, no `ccs`,
1797    /// no `condition_id`) and prove the scalar readers work + the legacy
1798    /// 1/K0 -> CCS -> 1/K0 adapter round-trips under the read mobility env.
1799    #[test]
1800    fn scalar_readers_on_legacy_db() {
1801        let path = std::env::temp_dir().join(format!(
1802            "rustdf_scalar_test_{}.db",
1803            std::process::id()
1804        ));
1805        let _ = std::fs::remove_file(&path);
1806        let one_over_k0 = 0.85_f64;
1807        let (mz, charge) = (500.25_f64, 2_i8);
1808        {
1809            let conn = Connection::open(&path).unwrap();
1810            conn.execute_batch(
1811                "CREATE TABLE peptides (
1812                    protein_id INTEGER, peptide_id INTEGER, sequence TEXT, protein TEXT,
1813                    decoy INTEGER, missed_cleavages INTEGER, n_term, c_term,
1814                    \"monoisotopic-mass\" REAL,
1815                    retention_time_gru_predictor REAL, rt_sigma REAL, rt_lambda REAL, events REAL
1816                 );
1817                 CREATE TABLE ions (
1818                    ion_id INTEGER, peptide_id INTEGER, sequence TEXT, charge INTEGER,
1819                    relative_abundance REAL, mz REAL, inv_mobility_gru_predictor REAL,
1820                    inv_mobility_gru_predictor_std REAL, simulated_spectrum TEXT
1821                 );",
1822            )
1823            .unwrap();
1824            conn.execute(
1825                "INSERT INTO peptides VALUES (1,1,'PEPTIDEK','P',0,0,NULL,NULL,930.5,123.4,1.2,0.3,5.0)",
1826                [],
1827            )
1828            .unwrap();
1829            conn.execute(
1830                "INSERT INTO ions VALUES (1,1,'PEPTIDEK',?1,1.0,?2,?3,0.02,'{\"mz\":[500.25],\"intensity\":[1.0]}')",
1831                rusqlite::params![charge, mz, one_over_k0],
1832            )
1833            .unwrap();
1834        }
1835
1836        let handle = TimsTofSyntheticsDataHandle::new(&path).unwrap();
1837
1838        // No experiment_conditions table -> default timsTOF env.
1839        let env = handle.read_mobility_env().unwrap();
1840        assert_eq!(env, MobilityEnv::default());
1841
1842        let peptides = handle.read_peptides_scalar().unwrap();
1843        assert_eq!(peptides.len(), 1);
1844        assert_eq!(peptides[0].rt_sigma, 1.2);
1845        assert_eq!(peptides[0].condition_id, None);
1846
1847        let ions = handle.read_ions_scalar(&env).unwrap();
1848        assert_eq!(ions.len(), 1);
1849        // CCS was derived from the legacy 1/K0; re-deriving 1/K0 from that CCS
1850        // under the same env must return the original (lossless migration).
1851        let back = ions[0].inv_mobility(&env);
1852        assert!((back - one_over_k0).abs() < 1e-9, "1/K0 round-trip: {back}");
1853        assert_eq!(ions[0].condition_id, None);
1854
1855        let _ = std::fs::remove_file(&path);
1856    }
1857
1858    /// P4a0: the source-aware reads produce same-shape PeptidesSim/IonSim from
1859    /// both `Columns` and `Projector`, and the projector path fills a non-empty
1860    /// distribution for a mid-gradient analyte.
1861    #[test]
1862    fn source_aware_reads_both_paths() {
1863        use crate::sim::containers::MobilityEnv;
1864        use crate::sim::projector::{DistributionSource, ProjectionMode, ProjectionParams};
1865        let path = std::env::temp_dir().join(format!("rustdf_p4a0_{}.db", std::process::id()));
1866        let _ = std::fs::remove_file(&path);
1867        {
1868            let conn = Connection::open(&path).unwrap();
1869            conn.execute_batch(
1870                "CREATE TABLE frames (frame_id INTEGER, time REAL, ms_type INTEGER);
1871                 CREATE TABLE scans (scan INTEGER, mobility REAL);
1872                 CREATE TABLE peptides (
1873                    protein_id INTEGER, peptide_id INTEGER, sequence TEXT, protein TEXT,
1874                    decoy INTEGER, missed_cleavages INTEGER, n_term, c_term,
1875                    \"monoisotopic-mass\" REAL, retention_time_gru_predictor REAL, rt_mu REAL,
1876                    rt_sigma REAL, rt_lambda REAL, events REAL, frame_occurrence TEXT,
1877                    frame_abundance TEXT, frame_occurrence_start INTEGER, frame_occurrence_end INTEGER);
1878                 CREATE TABLE ions (
1879                    ion_id INTEGER, peptide_id INTEGER, sequence TEXT, charge INTEGER,
1880                    relative_abundance REAL, mz REAL, inv_mobility_gru_predictor REAL,
1881                    inv_mobility_gru_predictor_std REAL, simulated_spectrum TEXT,
1882                    scan_occurrence TEXT, scan_abundance TEXT);",
1883            )
1884            .unwrap();
1885            // 40 uniform frames over ~4 s; the peptide elutes mid-gradient (rt_mu=2).
1886            for fid in 1..=40 {
1887                conn.execute(
1888                    "INSERT INTO frames VALUES (?1, ?2, 0)",
1889                    rusqlite::params![fid, fid as f64 * 0.1],
1890                )
1891                .unwrap();
1892            }
1893            for s in 0..20 {
1894                conn.execute(
1895                    "INSERT INTO scans VALUES (?1, ?2)",
1896                    rusqlite::params![s, 1.3 - s as f64 * 0.01],
1897                )
1898                .unwrap();
1899            }
1900            conn.execute(
1901                "INSERT INTO peptides VALUES (0,1,'PEPTIDEK','P',0,0,NULL,NULL,930.5,2.0,2.0,0.3,0.6,5.0,'[1]','[0.0]',1,1)",
1902                [],
1903            ).unwrap();
1904            conn.execute(
1905                "INSERT INTO ions VALUES (1,1,'PEPTIDEK',2,1.0,500.0,1.1,0.02,'{\"mz\":[500.0],\"intensity\":[1.0]}','[5]','[1.0]')",
1906                [],
1907            ).unwrap();
1908        }
1909        let handle = TimsTofSyntheticsDataHandle::new(&path).unwrap();
1910
1911        // Columns path == read_peptides/read_ions (stored values).
1912        let pc = handle.read_peptides_with_source(&DistributionSource::Columns).unwrap();
1913        let ic = handle.read_ions_with_source(&DistributionSource::Columns).unwrap();
1914        assert_eq!(pc.len(), 1);
1915        assert_eq!(ic.len(), 1);
1916        assert_eq!(pc[0].frame_distribution.occurrence, vec![1]); // the stored value
1917
1918        // Projector path produces a real distribution for the mid-gradient peptide.
1919        let src = DistributionSource::Projector {
1920            mode: ProjectionMode::LegacyCompat,
1921            env: MobilityEnv::default(),
1922            params: ProjectionParams::default(),
1923        };
1924        let pp = handle.read_peptides_with_source(&src).unwrap();
1925        let ip = handle.read_ions_with_source(&src).unwrap();
1926        assert_eq!(pp.len(), 1);
1927        assert_eq!(ip.len(), 1);
1928        assert!(
1929            pp[0].frame_distribution.occurrence.len() > 1,
1930            "projector should populate multiple frames for a mid-gradient peptide"
1931        );
1932        assert!(!ip[0].scan_distribution.occurrence.is_empty(), "projector should populate scans");
1933        // Accurate mode also runs.
1934        let src_acc = DistributionSource::Projector {
1935            mode: ProjectionMode::Accurate,
1936            env: MobilityEnv::default(),
1937            params: ProjectionParams::default(),
1938        };
1939        assert_eq!(handle.read_peptides_with_source(&src_acc).unwrap().len(), 1);
1940        assert_eq!(handle.read_ions_with_source(&src_acc).unwrap().len(), 1);
1941
1942        let _ = std::fs::remove_file(&path);
1943    }
1944}