1use std::io;
9
10#[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#[derive(Clone, Debug)]
24pub struct ScanDescriptor {
25 pub ms_level: u8,
26 pub retention_time: f64,
27 pub isolation: Option<IsolationWindow>,
29 pub peaks: Vec<(f64, f32)>,
31}
32
33impl ScanDescriptor {
34 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
88pub 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 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 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 #[derive(Clone, Copy, Debug)]
145 pub enum WriteMode {
146 Replace,
148 Overlay { merge_tol_ppm: f64 },
151 }
152
153 pub struct ThermoRawWriter {
170 raw: RawFile,
171 out_path: PathBuf,
172 calib: Calibration,
173 slots: Vec<(u32, u8, bool)>, cursor: usize,
180 mode: WriteMode,
181 allow_partial: bool,
186 deferred: Vec<DeferredEdit>,
190 }
191
192 struct DeferredEdit {
195 scan: u32,
196 peaks: Vec<(f64, f32)>,
197 is_profile: bool,
198 }
199
200 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 fn is_over_budget(e: &io::Error) -> bool {
211 thermorawfile::is_over_budget(e)
212 }
213
214 impl ThermoRawWriter {
215 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 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 let ms_level = raw
243 .scan_event(scan)
244 .map(|e| e.ms_order)
245 .unwrap_or(if is_profile { 1 } else { 2 });
246 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 pub fn with_mode(mut self, mode: WriteMode) -> Self {
276 self.mode = mode;
277 self
278 }
279
280 pub fn with_allow_partial(mut self, allow: bool) -> Self {
284 self.allow_partial = allow;
285 self
286 }
287
288 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 pub fn slot_count(&self) -> usize {
297 self.slots.len()
298 }
299
300 pub fn position(&self) -> usize {
302 self.cursor
303 }
304
305 pub fn is_complete(&self) -> bool {
309 self.cursor == self.slots.len()
310 }
311
312 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 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 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 match self.mode {
364 WriteMode::Replace => {
365 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 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 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 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 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 assert!(ScanDescriptor::from_rendered_event(
516 &ms2_event(vec![200.0], vec![f64::NAN]), 27.0
517 ).is_err());
518 assert!(ScanDescriptor::from_rendered_event(
520 &ms2_event(vec![f64::INFINITY], vec![1.0]), 27.0
521 ).is_err());
522 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 assert!(slot_level_matches(1, 1));
538 assert!(slot_level_matches(2, 2));
539 assert!(!slot_level_matches(1, 2)); assert!(!slot_level_matches(2, 1)); }
543
544 #[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 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 let rf = thermorawfile::RawFile::open(&out).expect("reopen");
586 assert!(rf.checksum_valid(), "checksum invalid");
587
588 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 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 #[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 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 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 #[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 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 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 #[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 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 #[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 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 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 #[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 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 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 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}