Skip to main content

rustdf/sim/
acquisition.rs

1//! Vendor-neutral acquisition-writer abstraction.
2//!
3//! [`AcquisitionWriter`] turns vendor-neutral [`ScanDescriptor`]s into a vendor
4//! raw file. The first implementation is [`ThermoRawWriter`] (Thermo `.raw`, via
5//! the `thermo` feature + the `thermorawfile` crate); a Bruker `.d` adapter over
6//! the existing write path is a planned follow-up.
7
8use std::io;
9
10/// MS2 quadrupole isolation window.
11#[derive(Clone, Copy, Debug)]
12pub struct IsolationWindow {
13    pub center_mz: f64,
14    pub width_mz: f64,
15    pub collision_energy: f64,
16}
17
18/// One vendor-neutral scan: a peak list plus acquisition metadata.
19///
20/// Intentionally has **no ion-mobility dimension** — it is the
21/// Bruker∩Thermo∩SCIEX common denominator. IMS-bearing Bruker frames are handled
22/// by the Bruker writer, which can flatten or carry mobility separately.
23#[derive(Clone, Debug)]
24pub struct ScanDescriptor {
25    pub ms_level: u8,
26    pub retention_time: f64,
27    /// Present for MS2 (the precursor isolation window).
28    pub isolation: Option<IsolationWindow>,
29    /// `(m/z, intensity)` pairs; need not be sorted (writers sort).
30    pub peaks: Vec<(f64, f32)>,
31}
32
33impl ScanDescriptor {
34    /// Convert a rendered event into a writer-ready descriptor (P6e-2 glue).
35    ///
36    /// `collision_energy` is the window's applied CE (eV/NCE) — it is NOT carried
37    /// on [`crate::sim::projector::RenderedEvent`]'s isolation (which is m/z-only),
38    /// so the driver supplies it. A [`crate::sim::projector::RenderedEvent::MobilityFrame`]
39    /// is not writable to a scan-based (Thermo) file. Peak m/z and intensity are
40    /// validated finite and the intensity is range-checked into `f32` (claudex):
41    /// a non-finite or `f32`-overflowing value is a hard error rather than a NaN/inf
42    /// silently authored into the packet.
43    pub fn from_rendered_event(
44        ev: &crate::sim::projector::RenderedEvent,
45        collision_energy: f64,
46    ) -> Result<ScanDescriptor, String> {
47        use crate::sim::projector::RenderedEvent;
48        let (ms_level, retention_time, isolation, spectrum) = match ev {
49            RenderedEvent::Scan { ms_level, retention_time_s, isolation, spectrum } => {
50                let iso = isolation.map(|w| IsolationWindow {
51                    center_mz: w.center_mz,
52                    width_mz: w.width_mz,
53                    collision_energy,
54                });
55                (*ms_level, *retention_time_s, iso, spectrum)
56            }
57            RenderedEvent::MobilityFrame { .. } => {
58                return Err("a MobilityFrame cannot be written to a scan-based (Thermo) file; \
59                            it is an IMS instrument's output"
60                    .to_string());
61            }
62        };
63        if spectrum.mz.len() != spectrum.intensity.len() {
64            return Err(format!(
65                "rendered spectrum m/z ({}) and intensity ({}) length mismatch",
66                spectrum.mz.len(),
67                spectrum.intensity.len()
68            ));
69        }
70        let mut peaks = Vec::with_capacity(spectrum.mz.len());
71        for (&m, &i) in spectrum.mz.iter().zip(spectrum.intensity.iter()) {
72            if !m.is_finite() {
73                return Err(format!("non-finite peak m/z {m}"));
74            }
75            if !i.is_finite() {
76                return Err(format!("non-finite peak intensity {i} at m/z {m}"));
77            }
78            let i32 = i as f32;
79            if !i32.is_finite() {
80                return Err(format!("peak intensity {i} at m/z {m} overflows f32"));
81            }
82            peaks.push((m, i32));
83        }
84        Ok(ScanDescriptor { ms_level, retention_time, isolation, peaks })
85    }
86}
87
88/// A sink that writes vendor-neutral scans into a vendor raw file. Scans are
89/// written in acquisition order; `finalize` flushes the file (and fixes any
90/// integrity checksum).
91pub trait AcquisitionWriter {
92    fn write_scan(&mut self, scan: &ScanDescriptor) -> io::Result<()>;
93    fn finalize(&mut self) -> io::Result<()>;
94}
95
96#[cfg(feature = "thermo")]
97pub use thermo::{rewindow_thermo_template, ThermoRawWriter, WriteMode};
98
99#[cfg(feature = "thermo")]
100mod thermo {
101    use super::*;
102    use std::path::{Path, PathBuf};
103    use thermorawfile::{Calibration, RawFile};
104
105    /// Re-window a Thermo DIA template (Tier-2 3a): copy `src` to `dst` with every MS2
106    /// isolation window set to `isolation_width` (centers + CE kept — same cardinality).
107    /// Used as a pre-authoring step so a sim can request a custom DIA window width without
108    /// a matching real template. Returns the number of MS2 scans re-windowed.
109    ///
110    /// The new windows must then drive BOTH the schedule read and authoring — so callers
111    /// point `template_path` at `dst` for the rest of the run, keeping the acquisition DB
112    /// and the authored `.raw` consistent.
113    pub fn rewindow_thermo_template<P: AsRef<Path>, Q: AsRef<Path>>(
114        src: P,
115        dst: Q,
116        isolation_width: f64,
117    ) -> io::Result<usize> {
118        if !(isolation_width.is_finite() && isolation_width > 0.0) {
119            return Err(io::Error::new(
120                io::ErrorKind::InvalidInput,
121                format!("rewindow isolation_width must be finite and > 0, got {isolation_width}"),
122            ));
123        }
124        let mut raw = RawFile::open(src)?;
125        if !raw.has_scan_events() {
126            return Err(io::Error::new(
127                io::ErrorKind::InvalidData,
128                "template scan events could not be decoded; cannot re-window",
129            ));
130        }
131        let n = raw.rewindow_in_place(|_scan, ev| {
132            Some((ev.isolation_center, isolation_width, ev.collision_energy))
133        })?;
134        // Atomic publish: write to a sibling temp then rename, so a partial/failed write
135        // can never leave a corrupt file at `dst` that a later run might trust.
136        let dst = dst.as_ref();
137        let tmp = dst.with_extension("raw.rewindow.tmp");
138        raw.save(&tmp)?;
139        std::fs::rename(&tmp, dst)?;
140        Ok(n)
141    }
142
143    /// How `ThermoRawWriter` combines simulated peaks with the template scan.
144    #[derive(Clone, Copy, Debug)]
145    pub enum WriteMode {
146        /// Replace the template scan's signal with the simulated peaks.
147        Replace,
148        /// Add simulated peaks onto the template's real signal (real⊕sim);
149        /// `merge_tol_ppm` merges near-coincident centroids on MS2.
150        Overlay { merge_tol_ppm: f64 },
151    }
152
153    /// Authors scans into a Thermo `.raw` by **template-mutation**: open a real
154    /// `.raw`, overwrite template scans of matching type in acquisition order
155    /// (MS1 → profile via `author_profile`, MS2 → centroids via
156    /// `author_centroids`), and save on [`AcquisitionWriter::finalize`].
157    ///
158    /// The template supplies the frequency grid + calibration; synthetic peaks
159    /// are placed at their exact m/z within each packet's byte budget. The
160    /// calibration is read once from the first scan (it is ≈constant across a
161    /// run); each scan's own frequency grid is taken from its profile. For MS2,
162    /// a descriptor's [`IsolationWindow`] (center/width/CE) is authored into the
163    /// scan event via `set_isolation`, so the output's MS2 metadata reflects the
164    /// intended scheme rather than only the template's.
165    ///
166    /// Current limitation: this *replaces* the template scan's signal. A future
167    /// **overlay** mode (keep the real template signal and add simulated peaks)
168    /// would support the "reference run = layout + real noise" real⊕sim workflow.
169    pub struct ThermoRawWriter {
170        raw: RawFile,
171        out_path: PathBuf,
172        calib: Calibration,
173        /// Template slots in ACQUISITION (scan) order: `(scan_number, is_profile)`,
174        /// where `is_profile` marks an MS1 FTMS-profile slot (vs an MS2 centroid
175        /// slot). A SINGLE cursor walks this in order, so the caller must feed scans
176        /// in the template's schedule — a level mismatch is rejected (independent
177        /// MS1/MS2 cursors could not catch a reordered stream).
178        slots: Vec<(u32, u8, bool)>,   // (scan, ms_level, is_profile): level & encoding decoupled
179        cursor: usize,
180        mode: WriteMode,
181        /// Opt out of the zero-residual completeness check at `finalize` (e.g. a
182        /// smoke test authoring only a few of the template's slots). Off by default
183        /// so a real Replace run that does not fill every slot fails loudly rather
184        /// than saving a file with untouched template signal in the gaps.
185        allow_partial: bool,
186        /// Over-budget scans deferred during `write_scan` and applied in a SINGLE
187        /// `repack_many` rebuild at `finalize` — so growing many scans past the
188        /// template budget costs one data-section rebuild, not one splice per scan.
189        deferred: Vec<DeferredEdit>,
190    }
191
192    /// A scan whose authored payload overflowed its template packet, deferred for the
193    /// batch rebuild at finalize.
194    struct DeferredEdit {
195        scan: u32,
196        peaks: Vec<(f64, f32)>,
197        is_profile: bool,
198    }
199
200    /// Whether a descriptor's ms-level fits the next template slot type (MS1 ⇒
201    /// profile slot, MS2+ ⇒ centroid slot). Free fn so the ordering contract is
202    /// unit-testable without a template.
203    pub(crate) fn slot_level_matches(slot_ms_level: u8, scan_ms_level: u8) -> bool {
204        slot_ms_level == scan_ms_level
205    }
206
207    /// True iff `e` is the thermorawfile over-budget overflow — the one error the writer
208    /// recovers from (by repacking to grow the packet) rather than propagating. Delegates
209    /// to the crate's typed predicate so it can't drift from the error's wording.
210    fn is_over_budget(e: &io::Error) -> bool {
211        thermorawfile::is_over_budget(e)
212    }
213
214    impl ThermoRawWriter {
215        /// Open `template` and prepare to author into `out`.
216        pub fn from_template<P: AsRef<Path>, Q: AsRef<Path>>(
217            template: P,
218            out: Q,
219        ) -> io::Result<Self> {
220            let raw = RawFile::open(template)?;
221            let calib = raw
222                .calibration_at_event(raw.scantrailer_addr as usize + 4)
223                .ok_or_else(|| {
224                    io::Error::new(io::ErrorKind::InvalidData, "no MS1 calibration in template")
225                })?;
226
227            // Build the ordered slot manifest: a non-zero profile section marks an
228            // MS1-style (FTMS profile) slot; centroid-only is MS2 (ASTMS). Kept in
229            // scan order so a single cursor enforces the template's schedule.
230            let mut slots = Vec::with_capacity(raw.index.len());
231            for (i, e) in raw.index.iter().enumerate() {
232                let pkt = (raw.data_addr + e.offset) as usize;
233                if pkt + 8 > raw.bytes.len() {
234                    continue;
235                }
236                let profile_size =
237                    u32::from_le_bytes(raw.bytes[pkt + 4..pkt + 8].try_into().unwrap());
238                let scan = raw.first_scan + i as u32;
239                let is_profile = profile_size > 0;
240                // Real ms-level from the scan event, decoupled from profile/centroid (a Q Exactive
241                // HF-X DIA template has profile MS2; profile⇒MS1 is only an Astral coincidence).
242                let ms_level = raw
243                    .scan_event(scan)
244                    .map(|e| e.ms_order)
245                    .unwrap_or(if is_profile { 1 } else { 2 });
246                // GUARD (codex review): decoupling must not silently enable centroid-MS1 overlay,
247                // whose isotope-envelope/calibration semantics differ. Keep profile-MS1 explicit.
248                if ms_level <= 1 && !is_profile {
249                    return Err(io::Error::new(
250                        io::ErrorKind::InvalidData,
251                        format!(
252                            "centroid-MS1 template not supported for overlay (scan {}); \
253                             profile-MS1 required",
254                            scan
255                        ),
256                    ));
257                }
258                slots.push((scan, ms_level, is_profile));
259            }
260            Ok(Self {
261                raw,
262                out_path: out.as_ref().to_path_buf(),
263                calib,
264                slots,
265                cursor: 0,
266                mode: WriteMode::Replace,
267                allow_partial: false,
268                deferred: Vec::new(),
269            })
270        }
271
272        /// Set the write mode (default [`WriteMode::Replace`]). Use
273        /// [`WriteMode::Overlay`] for the reference-run = layout + real-noise
274        /// (real⊕sim) workflow.
275        pub fn with_mode(mut self, mode: WriteMode) -> Self {
276            self.mode = mode;
277            self
278        }
279
280        /// Permit `finalize` to save even when not every template slot was authored
281        /// (default: off). Only for partial-write smoke tests; a real Replace run
282        /// must fill every slot to honour the zero-residual contract.
283        pub fn with_allow_partial(mut self, allow: bool) -> Self {
284            self.allow_partial = allow;
285            self
286        }
287
288        /// Template MS1/MS2 capacity, so callers can check a run fits.
289        pub fn capacity(&self) -> (usize, usize) {
290            let ms1 = self.slots.iter().filter(|s| s.1 <= 1).count();
291            (ms1, self.slots.len() - ms1)
292        }
293
294        /// Total number of template slots (the exact scan count a complete run
295        /// must author, in order).
296        pub fn slot_count(&self) -> usize {
297            self.slots.len()
298        }
299
300        /// Number of slots authored so far.
301        pub fn position(&self) -> usize {
302            self.cursor
303        }
304
305        /// Whether every template slot has been authored (the zero-residual
306        /// contract: a `Replace`-mode run that does not fill the template leaves
307        /// real template signal in the unconsumed slots).
308        pub fn is_complete(&self) -> bool {
309            self.cursor == self.slots.len()
310        }
311
312        /// The ordered slot manifest `(scan_number, is_profile)` — for preflight
313        /// (matching the run's MS-level sequence to the template before writing).
314        pub fn manifest(&self) -> &[(u32, u8, bool)] {
315            &self.slots
316        }
317    }
318
319    impl AcquisitionWriter for ThermoRawWriter {
320        fn write_scan(&mut self, scan: &ScanDescriptor) -> io::Result<()> {
321            let (t, slot_ms_level, is_profile) = *self.slots.get(self.cursor).ok_or_else(|| {
322                io::Error::new(
323                    io::ErrorKind::InvalidInput,
324                    format!(
325                        "template exhausted: {} slots, tried to author slot {}",
326                        self.slots.len(),
327                        self.cursor + 1
328                    ),
329                )
330            })?;
331            // Enforce the template's GLOBAL scan order: the descriptor's level must
332            // match the next slot's type. A single cursor catches a reordered stream
333            // (MS1,MS2,MS1,MS2 vs MS1,MS1,MS2,MS2) that independent cursors could not.
334            if !slot_level_matches(slot_ms_level, scan.ms_level) {
335                return Err(io::Error::new(
336                    io::ErrorKind::InvalidInput,
337                    format!(
338                        "scan-order mismatch at slot {} (template scan {}): template slot \
339                         ms_level {} (is_profile {}) but got ms_level {}",
340                        self.cursor,
341                        t,
342                        slot_ms_level,
343                        is_profile,
344                        scan.ms_level
345                    ),
346                ));
347            }
348            // Overlay with no simulated peaks is a true no-op: leave the template
349            // slot's REAL signal byte-identical. Don't round-trip it through
350            // overlay_profile/overlay_centroids, which re-canonicalize the packet
351            // (and could merge near-coincident real centroids within merge_tol_ppm).
352            // Replace with no peaks still clears the slot (pure-simulated, zero-
353            // residual) — that path falls through below.
354            if scan.peaks.is_empty() {
355                if let WriteMode::Overlay { .. } = self.mode {
356                    self.cursor += 1;
357                    return Ok(());
358                }
359            }
360            if is_profile {
361                // Advance the cursor only after authoring succeeds, so a failed
362                // write doesn't permanently consume the slot.
363                match self.mode {
364                    WriteMode::Replace => {
365                        // Fast path: author in place. On (and only on) an over-budget
366                        // overflow, DEFER the scan — it can hold MORE peaks than the
367                        // template slot, but growing it relocates the file tail, so we
368                        // batch all overflows into one `repack_many` rebuild at finalize
369                        // instead of a splice per scan. The common in-budget write stays
370                        // in place; nothing is cleared (the old lossy behaviour).
371                        match self.raw.author_profile(t, &scan.peaks, &self.calib) {
372                            Ok(()) => {}
373                            Err(e) if is_over_budget(&e) => self.deferred.push(DeferredEdit {
374                                scan: t,
375                                peaks: scan.peaks.clone(),
376                                is_profile: true,
377                            }),
378                            Err(e) => return Err(e),
379                        }
380                    }
381                    WriteMode::Overlay { .. } => {
382                        self.raw.overlay_profile(t, &scan.peaks, &self.calib)?
383                    }
384                }
385            } else {
386                match self.mode {
387                    WriteMode::Replace => {
388                        match self.raw.author_centroids(t, &scan.peaks) {
389                            Ok(()) => {}
390                            Err(e) if is_over_budget(&e) => self.deferred.push(DeferredEdit {
391                                scan: t,
392                                peaks: scan.peaks.clone(),
393                                is_profile: false,
394                            }),
395                            Err(e) => return Err(e),
396                        }
397                        // Author the descriptor's isolation window + CE into the scan
398                        // event. Independent of the data-packet size, and the scan-event
399                        // bytes are preserved (relocated) by a later batch rebuild, so it
400                        // is correct to set here for both in-place and deferred scans.
401                        if let Some(iso) = scan.isolation {
402                            self.raw.set_isolation(
403                                t,
404                                iso.center_mz,
405                                iso.width_mz,
406                                iso.collision_energy,
407                            )?;
408                        }
409                    }
410                    // Overlay keeps the template's real signal + acquisition
411                    // metadata (the scheme is derived from this template), so the
412                    // isolation/CE are left as the template's.
413                    WriteMode::Overlay { merge_tol_ppm } => {
414                        self.raw.overlay_centroids(t, &scan.peaks, merge_tol_ppm)?
415                    }
416                }
417            }
418            self.cursor += 1;
419            Ok(())
420        }
421
422        fn finalize(&mut self) -> io::Result<()> {
423            // Apply all deferred over-budget scans FIRST, in a SINGLE data-section
424            // rebuild (O(file size), vs one splice per scan). The in-place author_*
425            // writes have already landed; this grows only the scans that overflowed
426            // their slot. Done before the residual check so the file is fully authored
427            // by the time any validation runs (the check below is cursor-based today,
428            // but applying first keeps it correct even if it ever inspects bytes).
429            //
430            // Note: each deferred scan's peaks are held cloned until here, and
431            // repack_many transiently allocates a second copy of the data section. For a
432            // run where MOST scans overflow on a multi-GB template, peak memory is
433            // roughly file + rebuilt-data-section + deferred payloads; acceptable for now
434            // but the ceiling to watch if this is pushed to huge inputs.
435            if !self.deferred.is_empty() {
436                let edits: Vec<thermorawfile::ScanEdit> = self
437                    .deferred
438                    .iter()
439                    .map(|d| {
440                        if d.is_profile {
441                            thermorawfile::ScanEdit::Profile {
442                                scan: d.scan,
443                                peaks: &d.peaks,
444                                calib: &self.calib,
445                            }
446                        } else {
447                            thermorawfile::ScanEdit::Centroids { scan: d.scan, peaks: &d.peaks }
448                        }
449                    })
450                    .collect();
451                self.raw.repack_many(&edits)?;
452            }
453
454            // Zero-residual contract: in Replace mode every template slot must have
455            // been authored (deferred overflow scans counted — their cursor advanced and
456            // they were just rebuilt above), else the saved file keeps the template's
457            // REAL signal in the unconsumed slots (a partial/cancelled run masquerading
458            // as valid output). Overlay mode intentionally retains template signal, so it
459            // is exempt; `allow_partial` opts out for partial-write smoke tests.
460            if matches!(self.mode, WriteMode::Replace) && !self.allow_partial && !self.is_complete() {
461                return Err(io::Error::new(
462                    io::ErrorKind::InvalidInput,
463                    format!(
464                        "incomplete Replace run: {}/{} template slots authored — the \
465                         remaining {} slots still hold the template's real signal. \
466                         Author every slot, or use with_allow_partial(true).",
467                        self.cursor,
468                        self.slots.len(),
469                        self.slots.len() - self.cursor
470                    ),
471                ));
472            }
473            let path = self.out_path.clone();
474            self.raw.save(path)
475        }
476    }
477}
478
479#[cfg(test)]
480mod glue_tests {
481    use super::*;
482    use crate::sim::projector::{IntensityStage, MzCoordSpace, RenderedEvent, RenderedSpectrum};
483    use crate::sim::scheme::{DataMode, IsolationWindow as SchemeIso};
484
485    fn ms2_event(mz: Vec<f64>, intensity: Vec<f64>) -> RenderedEvent {
486        RenderedEvent::Scan {
487            ms_level: 2,
488            retention_time_s: 12.5,
489            isolation: Some(SchemeIso { center_mz: 600.0, width_mz: 25.0 }),
490            spectrum: RenderedSpectrum {
491                mz,
492                intensity,
493                coords: MzCoordSpace::Physical,
494                mode: DataMode::Centroid,
495                detector_applied: false,
496                stage: IntensityStage::Transmitted,
497            },
498        }
499    }
500
501    #[test]
502    fn from_rendered_event_maps_and_attaches_ce() {
503        let d = ScanDescriptor::from_rendered_event(&ms2_event(vec![200.0, 300.0], vec![10.0, 20.0]), 27.0)
504            .expect("valid");
505        assert_eq!(d.ms_level, 2);
506        assert!((d.retention_time - 12.5).abs() < 1e-9);
507        let iso = d.isolation.expect("MS2 isolation");
508        assert!((iso.center_mz - 600.0).abs() < 1e-9 && (iso.collision_energy - 27.0).abs() < 1e-9);
509        assert_eq!(d.peaks, vec![(200.0, 10.0f32), (300.0, 20.0f32)]);
510    }
511
512    #[test]
513    fn from_rendered_event_rejects_nonfinite_and_mobility() {
514        // Non-finite intensity is a hard error (not a NaN authored into the packet).
515        assert!(ScanDescriptor::from_rendered_event(
516            &ms2_event(vec![200.0], vec![f64::NAN]), 27.0
517        ).is_err());
518        // Non-finite m/z too.
519        assert!(ScanDescriptor::from_rendered_event(
520            &ms2_event(vec![f64::INFINITY], vec![1.0]), 27.0
521        ).is_err());
522        // A MobilityFrame is not writable to a scan-based file.
523        let mob = RenderedEvent::MobilityFrame { ms_level: 1, retention_time_s: 0.0, scans: vec![] };
524        assert!(ScanDescriptor::from_rendered_event(&mob, 0.0).is_err());
525    }
526}
527
528#[cfg(all(test, feature = "thermo"))]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn slot_level_matches_enforces_ordering_contract() {
534        use super::thermo::slot_level_matches;
535        // slot_level_matches now compares ACTUAL ms-levels (template slot vs simulated scan),
536        // decoupled from profile/centroid encoding.
537        assert!(slot_level_matches(1, 1));
538        assert!(slot_level_matches(2, 2));
539        // Mismatches the single-cursor writer rejects (reordered MS1/MS2 stream).
540        assert!(!slot_level_matches(1, 2)); // MS2 scan into an MS1 slot
541        assert!(!slot_level_matches(2, 1)); // MS1 scan into an MS2 slot
542    }
543
544    // Gated: set TIMSIM_ASTRAL_TEMPLATE to a real Orbitrap Astral .raw to run.
545    // `cargo test --features thermo -- --nocapture thermo_roundtrip`
546    #[test]
547    fn thermo_roundtrip() {
548        let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
549            Ok(p) => p,
550            Err(_) => {
551                eprintln!("SKIP thermo_roundtrip: set TIMSIM_ASTRAL_TEMPLATE=<astral .raw>");
552                return;
553            }
554        };
555        let out = std::env::temp_dir().join("rustdf_thermo_roundtrip.raw");
556
557        // Authors only 2 of the template's many slots, so opt into partial finalize.
558        let mut w = ThermoRawWriter::from_template(&template, &out)
559            .expect("open template")
560            .with_allow_partial(true);
561        let (n_ms1, n_ms2) = w.capacity();
562        assert!(n_ms1 > 0 && n_ms2 > 0, "template has no MS1/MS2 scans");
563
564        let ms1 = ScanDescriptor {
565            ms_level: 1,
566            retention_time: 0.0,
567            isolation: None,
568            peaks: vec![(500.0, 1.0e6), (700.0, 5.0e5)],
569        };
570        let ms2 = ScanDescriptor {
571            ms_level: 2,
572            retention_time: 0.01,
573            isolation: Some(IsolationWindow {
574                center_mz: 500.0,
575                width_mz: 2.0,
576                collision_energy: 25.0,
577            }),
578            peaks: vec![(150.1, 3.0e4), (420.2, 8.0e4), (610.3, 5.0e4)],
579        };
580        w.write_scan(&ms1).expect("write MS1");
581        w.write_scan(&ms2).expect("write MS2");
582        w.finalize().expect("finalize");
583
584        // Read back through thermorawfile and confirm the authored peaks.
585        let rf = thermorawfile::RawFile::open(&out).expect("reopen");
586        assert!(rf.checksum_valid(), "checksum invalid");
587
588        // The writer used the first profile scan for MS1 and first centroid scan for MS2.
589        let cal = rf
590            .calibration_at_event(rf.scantrailer_addr as usize + 4)
591            .unwrap();
592        let mut prof_scan = None;
593        let mut cent_scan = None;
594        for i in 0..rf.index.len() {
595            let scan = rf.first_scan + i as u32;
596            let pkt = (rf.data_addr + rf.index[i].offset) as usize;
597            let psize = u32::from_le_bytes(rf.bytes[pkt + 4..pkt + 8].try_into().unwrap());
598            if psize > 0 && prof_scan.is_none() {
599                prof_scan = Some(scan);
600            }
601            if psize == 0 && cent_scan.is_none() {
602                cent_scan = Some(scan);
603            }
604        }
605        let prof = rf.profile(prof_scan.unwrap()).expect("ms1 profile");
606        assert_eq!(prof.chunks.len(), 2, "MS1 peak count");
607        let ms1_mz: Vec<f64> = prof
608            .chunks
609            .iter()
610            .map(|c| prof.mz_of_bin(c.first_bin, &cal))
611            .collect();
612        assert!((ms1_mz[0] - 500.0).abs() < 0.01 && (ms1_mz[1] - 700.0).abs() < 0.01);
613
614        let cents = rf.centroid_peaks(cent_scan.unwrap());
615        assert_eq!(cents.len(), 3, "MS2 peak count");
616        assert!((cents[0].mz - 150.1).abs() < 0.01);
617        assert!((cents[2].mz - 610.3).abs() < 0.01);
618
619        // The authored isolation window + CE must be reflected in the scan event.
620        let ev = rf.scan_event(cent_scan.unwrap()).expect("ms2 scan event");
621        assert!((ev.isolation_center - 500.0).abs() < 0.01, "authored isolation center");
622        assert!((ev.isolation_width - 2.0).abs() < 0.01, "authored isolation width");
623        assert!((ev.collision_energy - 25.0).abs() < 0.1, "authored CE");
624
625        eprintln!(
626            "thermo_roundtrip OK: MS1 {:?}, MS2 {} peaks, MS2 iso {:.2}±{:.2} CE {:.1}",
627            ms1_mz, cents.len(), ev.isolation_center, ev.isolation_width, ev.collision_energy
628        );
629    }
630
631    // Gated: an over-budget MS2 must GROW via the repack fallback, not be cleared.
632    #[test]
633    fn thermo_overflow_repack() {
634        let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
635            Ok(p) => p,
636            Err(_) => {
637                eprintln!("SKIP thermo_overflow_repack: set TIMSIM_ASTRAL_TEMPLATE=<astral .raw>");
638                return;
639            }
640        };
641        let out = std::env::temp_dir().join("rustdf_thermo_overflow.raw");
642        let mut w = ThermoRawWriter::from_template(&template, &out)
643            .expect("open template")
644            .with_allow_partial(true);
645
646        let ms1 = ScanDescriptor {
647            ms_level: 1,
648            retention_time: 0.0,
649            isolation: None,
650            peaks: vec![(500.0, 1.0e6), (700.0, 5.0e5)],
651        };
652        // Far more peaks than any realistic centroid packet budget → forces the repack.
653        let big: Vec<(f64, f32)> = (0..6000)
654            .map(|i| (200.0 + i as f64 * 0.1, 100.0 + i as f32))
655            .collect();
656        let ms2 = ScanDescriptor {
657            ms_level: 2,
658            retention_time: 0.01,
659            isolation: Some(IsolationWindow {
660                center_mz: 500.0,
661                width_mz: 2.0,
662                collision_energy: 25.0,
663            }),
664            peaks: big.clone(),
665        };
666
667        w.write_scan(&ms1).expect("write MS1");
668        // The assertion that matters: an over-budget MS2 now SUCCEEDS (grows via repack)
669        // instead of erroring / being cleared to empty.
670        w.write_scan(&ms2)
671            .expect("over-budget MS2 must grow via the repack fallback");
672        w.finalize().expect("finalize");
673
674        let rf = thermorawfile::RawFile::open(&out).expect("reopen");
675        assert!(rf.checksum_valid(), "checksum after in-sim repack");
676        let mut cent = None;
677        for i in 0..rf.index.len() {
678            let scan = rf.first_scan + i as u32;
679            let pkt = (rf.data_addr + rf.index[i].offset) as usize;
680            let psize = u32::from_le_bytes(rf.bytes[pkt + 4..pkt + 8].try_into().unwrap());
681            if psize == 0 {
682                cent = Some(scan);
683                break;
684            }
685        }
686        let n = rf.centroid_peaks(cent.unwrap()).len();
687        assert_eq!(n, big.len(), "repacked MS2 must keep ALL peaks, not be cleared");
688        eprintln!("thermo_overflow_repack OK: MS2 grown to {n} peaks via the repack fallback");
689    }
690
691    // Gated: MANY over-budget MS2 scans must all grow via ONE batch rebuild at finalize.
692    #[test]
693    fn thermo_overflow_batch() {
694        let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
695            Ok(p) => p,
696            Err(_) => {
697                eprintln!("SKIP thermo_overflow_batch: set TIMSIM_ASTRAL_TEMPLATE=<astral .raw>");
698                return;
699            }
700        };
701        let out = std::env::temp_dir().join("rustdf_thermo_overflow_batch.raw");
702        let mut w = ThermoRawWriter::from_template(&template, &out)
703            .expect("open template")
704            .with_allow_partial(true);
705
706        // One MS1, then several over-budget MS2 (the Astral schedule has many MS2 per
707        // MS1, so consecutive MS2 slots follow the survey). Each is deferred and applied
708        // in a single repack_many at finalize.
709        w.write_scan(&ScanDescriptor {
710            ms_level: 1,
711            retention_time: 0.0,
712            isolation: None,
713            peaks: vec![(500.0, 1.0e6), (700.0, 5.0e5)],
714        })
715        .expect("write MS1");
716
717        let sizes = [3000usize, 4000, 5000, 3500, 4500];
718        for (k, &sz) in sizes.iter().enumerate() {
719            let big: Vec<(f64, f32)> = (0..sz)
720                .map(|i| (200.0 + i as f64 * 0.05, 100.0 + i as f32))
721                .collect();
722            w.write_scan(&ScanDescriptor {
723                ms_level: 2,
724                retention_time: 0.01 * (k + 1) as f64,
725                isolation: Some(IsolationWindow {
726                    center_mz: 400.0 + k as f64,
727                    width_mz: 2.0,
728                    collision_energy: 25.0,
729                }),
730                peaks: big,
731            })
732            .unwrap_or_else(|e| panic!("write over-budget MS2 #{k}: {e}"));
733        }
734        w.finalize().expect("finalize (batch rebuild)");
735
736        let rf = thermorawfile::RawFile::open(&out).expect("reopen");
737        assert!(rf.checksum_valid(), "checksum after batch rebuild");
738        // The first 5 centroid scans (the authored MS2) must carry the grown counts.
739        let cent_scans: Vec<u32> = (rf.first_scan..=rf.last_scan)
740            .filter(|&s| {
741                let i = (s - rf.first_scan) as usize;
742                let pkt = (rf.data_addr + rf.index[i].offset) as usize;
743                u32::from_le_bytes(rf.bytes[pkt + 4..pkt + 8].try_into().unwrap()) == 0
744            })
745            .take(sizes.len())
746            .collect();
747        for (k, &s) in cent_scans.iter().enumerate() {
748            assert_eq!(rf.centroid_peaks(s).len(), sizes[k], "MS2 scan {s} grown count");
749        }
750        eprintln!("thermo_overflow_batch OK: {} MS2 grown via one repack_many {:?}", sizes.len(), sizes);
751    }
752
753    // Gated (Tier-2 3a): rewindow_thermo_template sets every MS2 window to a new width.
754    // Gate on a DIA template: `TIMSIM_VARLEN_DIA_TEMPLATE=<dia .raw>`.
755    #[test]
756    fn rewindow_thermo_template_sets_width() {
757        let src = match std::env::var("TIMSIM_VARLEN_DIA_TEMPLATE") {
758            Ok(p) => p,
759            Err(_) => {
760                eprintln!("SKIP rewindow_thermo_template_sets_width: set TIMSIM_VARLEN_DIA_TEMPLATE=<dia .raw>");
761                return;
762            }
763        };
764        let dst = std::env::temp_dir().join("rustdf_rewindow_5th.raw");
765        let n = super::rewindow_thermo_template(&src, &dst, 5.0).expect("rewindow");
766        assert!(n > 0, "expected MS2 scans re-windowed");
767        let rf = thermorawfile::RawFile::open(&dst).expect("reopen");
768        assert!(rf.checksum_valid());
769        let widths_ok = (rf.first_scan..=rf.last_scan)
770            .filter_map(|s| rf.scan_event(s))
771            .filter(|e| e.ms_order >= 2)
772            .all(|e| (e.isolation_width - 5.0).abs() < 1e-6);
773        assert!(widths_ok, "every MS2 window must now be 5.0 Th");
774        // bad width rejected
775        assert!(super::rewindow_thermo_template(&src, &dst, 0.0).is_err());
776        eprintln!("rewindow_thermo_template OK: {n} MS2 windows -> 5.0 Th");
777    }
778
779    // Gated: overlay (real⊕sim) — sim peaks added onto the template's real signal.
780    #[test]
781    fn thermo_overlay() {
782        let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
783            Ok(p) => p,
784            Err(_) => {
785                eprintln!("SKIP thermo_overlay: set TIMSIM_ASTRAL_TEMPLATE=<astral .raw>");
786                return;
787            }
788        };
789        let out = std::env::temp_dir().join("rustdf_thermo_overlay.raw");
790
791        // Baseline counts from the untouched template.
792        let base = thermorawfile::RawFile::open(&template).unwrap();
793        let (mut prof_scan, mut cent_scan) = (None, None);
794        for i in 0..base.index.len() {
795            let scan = base.first_scan + i as u32;
796            let pkt = (base.data_addr + base.index[i].offset) as usize;
797            let psize = u32::from_le_bytes(base.bytes[pkt + 4..pkt + 8].try_into().unwrap());
798            if psize > 0 && prof_scan.is_none() { prof_scan = Some(scan); }
799            if psize == 0 && cent_scan.is_none() { cent_scan = Some(scan); }
800        }
801        let base_pts = base.profile(prof_scan.unwrap()).unwrap().point_count();
802        let base_cents = base.centroid_peaks(cent_scan.unwrap()).len();
803
804        let mut w = ThermoRawWriter::from_template(&template, &out)
805            .expect("open")
806            .with_mode(WriteMode::Overlay { merge_tol_ppm: 10.0 });
807        w.write_scan(&ScanDescriptor {
808            ms_level: 1, retention_time: 0.0, isolation: None,
809            peaks: vec![(555.5, 9.9e5), (744.4, 7.7e5)],
810        }).unwrap();
811        w.write_scan(&ScanDescriptor {
812            ms_level: 2, retention_time: 0.01,
813            isolation: Some(IsolationWindow { center_mz: 490.0, width_mz: 2.0, collision_energy: 25.0 }),
814            peaks: vec![(333.33, 5.0e5), (888.88, 4.0e5)],
815        }).unwrap();
816        w.finalize().unwrap();
817
818        let rf = thermorawfile::RawFile::open(&out).unwrap();
819        assert!(rf.checksum_valid());
820        let cal = rf.calibration_at_event(rf.scantrailer_addr as usize + 4).unwrap();
821        let prof = rf.profile(prof_scan.unwrap()).unwrap();
822        // Real signal retained (point count grew, not replaced) + sim m/z present.
823        assert!(prof.point_count() >= base_pts, "real profile points dropped");
824        let has = |mz: f64| prof.chunks.iter().any(|c|
825            (0..c.signal.len()).any(|j| (prof.mz_of_bin(c.first_bin + j as u32, &cal) - mz).abs() < 0.02));
826        assert!(has(555.5) && has(744.4), "sim MS1 peaks missing");
827        let cents = rf.centroid_peaks(cent_scan.unwrap());
828        assert!(cents.len() >= base_cents + 1, "MS2 real centroids not retained / sim not added");
829        eprintln!("thermo_overlay OK: MS1 {}->{} pts (+sim), MS2 {}->{} centroids",
830            base_pts, prof.point_count(), base_cents, cents.len());
831    }
832
833    // Gated: an EMPTY overlay must be a true no-op — the template's real signal is
834    // left untouched. The short-circuit returns before `self.raw` is mutated, so the
835    // packet bytes are never round-tripped through overlay/author (which would
836    // re-canonicalize and could merge near-coincident real centroids). We assert the
837    // DECODED signal is bit-identical (exact equality: nothing is recomputed, so the
838    // decode of an untouched packet is deterministic). This is the contract the
839    // dispatch's overflow fallback relies on in Overlay mode (an overflowing slot is
840    // re-authored empty and must keep its real signal).
841    #[test]
842    fn overlay_empty_is_noop() {
843        let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
844            Ok(p) => p,
845            Err(_) => {
846                eprintln!("SKIP overlay_empty_is_noop: set TIMSIM_ASTRAL_TEMPLATE=<astral .raw>");
847                return;
848            }
849        };
850        let out = std::env::temp_dir().join("rustdf_overlay_empty_noop.raw");
851
852        // Baseline: decoded MS1 profile + MS2 centroids of the first NON-EMPTY
853        // profile/centroid scans (a vacuous empty scan would make the invariant
854        // trivially hold), straight from the untouched template.
855        let base = thermorawfile::RawFile::open(&template).unwrap();
856        let (mut prof_scan, mut cent_scan) = (None, None);
857        for i in 0..base.index.len() {
858            let scan = base.first_scan + i as u32;
859            let pkt = (base.data_addr + base.index[i].offset) as usize;
860            let psize = u32::from_le_bytes(base.bytes[pkt + 4..pkt + 8].try_into().unwrap());
861            if psize > 0 && prof_scan.is_none()
862                && base.profile(scan).map(|p| p.point_count() > 0).unwrap_or(false)
863            {
864                prof_scan = Some(scan);
865            }
866            if psize == 0 && cent_scan.is_none() && !base.centroid_peaks(scan).is_empty() {
867                cent_scan = Some(scan);
868            }
869            if prof_scan.is_some() && cent_scan.is_some() { break; }
870        }
871        let prof_scan = prof_scan.expect("template has no non-empty MS1 profile scan");
872        let cent_scan = cent_scan.expect("template has no non-empty MS2 centroid scan");
873        let cal = base.calibration_at_event(base.scantrailer_addr as usize + 4).unwrap();
874        let profile_points = |rf: &thermorawfile::RawFile, scan: u32| -> Vec<(f64, f32)> {
875            let p = rf.profile(scan).unwrap();
876            let mut v = Vec::with_capacity(p.point_count());
877            for c in &p.chunks {
878                for j in 0..c.signal.len() {
879                    v.push((p.mz_of_bin(c.first_bin + j as u32, &cal), c.signal[j]));
880                }
881            }
882            v
883        };
884        let base_prof = profile_points(&base, prof_scan);
885        let base_cents: Vec<(f64, f32)> =
886            base.centroid_peaks(cent_scan).iter().map(|p| (p.mz, p.intensity)).collect();
887        assert!(!base_prof.is_empty(), "picked an empty MS1 profile — test would be vacuous");
888        assert!(!base_cents.is_empty(), "picked an empty MS2 centroid — test would be vacuous");
889
890        // Walk every slot in order, authoring an EMPTY descriptor in Overlay mode.
891        // A complete pass exercises the finalize path; Overlay is exempt from the
892        // zero-residual check, and every empty author short-circuits.
893        let mut w = ThermoRawWriter::from_template(&template, &out)
894            .expect("open")
895            .with_mode(WriteMode::Overlay { merge_tol_ppm: 20.0 });
896        for &(_, _, is_profile) in w.manifest().to_vec().iter() {
897            w.write_scan(&ScanDescriptor {
898                ms_level: if is_profile { 1 } else { 2 },
899                retention_time: 0.0,
900                isolation: None,
901                peaks: Vec::new(),
902            })
903            .expect("empty overlay author");
904        }
905        w.finalize().expect("finalize");
906
907        let rf = thermorawfile::RawFile::open(&out).unwrap();
908        assert!(rf.checksum_valid(), "checksum invalid");
909        let out_prof = profile_points(&rf, prof_scan);
910        let out_cents: Vec<(f64, f32)> =
911            rf.centroid_peaks(cent_scan).iter().map(|p| (p.mz, p.intensity)).collect();
912        // Real signal bit-identical — empty overlay touched nothing (exact equality:
913        // the packet is never mutated, so the decode is deterministic).
914        assert_eq!(out_prof, base_prof, "MS1 profile changed under empty overlay");
915        assert_eq!(out_cents, base_cents, "MS2 centroids changed under empty overlay");
916        eprintln!(
917            "overlay_empty_is_noop OK: MS1 {} pts + MS2 {} centroids unchanged across full empty pass",
918            base_prof.len(), base_cents.len()
919        );
920    }
921}