diff --git a/Cargo.lock b/Cargo.lock index b36b69b..f81c5d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -2283,6 +2294,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -4226,7 +4243,9 @@ name = "plotx-io" version = "0.1.0" dependencies = [ "base64", + "byteorder", "calamine", + "cfb", "flate2", "image", "memmap2", diff --git a/README.md b/README.md index 72d28ec..e81cbe7 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ preparation. ## Highlights - **Bring scientific data together.** Current import support includes Axon - ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML and Waters - MassLynx LC–MS runs, JEOL Delta, Bruker TopSpin, and Varian/Agilent VnmrJ - experiments, JCAMP-DX spectra, archives, and delimited tables. + ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML, Waters + MassLynx, and legacy SCIEX WIFF LC–MS runs, JEOL Delta, Bruker TopSpin, + and Varian/Agilent VnmrJ experiments, JCAMP-DX spectra, archives, and + delimited tables. - **Process and analyze interactively.** Build ordered processing pipelines, then pick peaks, integrate regions, and fit data. NMR workflows also include DOSY and relaxation analysis, plus sweep statistics and IV analysis for diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index b26509f..9173989 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -379,7 +379,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { &["png", "jpg", "jpeg", "tif", "tiff", "webp", "bmp"], ) .add_filter( - "All supported data (*.mzML, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", + "All supported data (*.mzML, *.wiff, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", origin::OPEN_FILE_FILTER_EXTENSIONS, ) .add_filter("Rigaku XRD (*.rasx, *.raw, *.txt)", &["rasx", "raw", "txt"]) @@ -391,6 +391,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { .add_filter("Axon Binary Format 2 (*.abf)", &["abf"]) .add_filter("JEOL Delta (*.jdf)", &["jdf"]) .add_filter("mzML mass spectrometry (*.mzML)", &["mzML"]) + .add_filter("SCIEX legacy WIFF (*.wiff)", &["wiff"]) .add_filter("XPS (*.vms, CasaXPS *.txt)", &["vms", "txt"]) .add_filter("Bruker TopSpin (fid, ser)", &["fid", "ser"]) .add_filter("Varian/Agilent VnmrJ (fid)", &["fid"]) @@ -427,7 +428,7 @@ pub(crate) fn choose_project_save_path() -> Option { pub(crate) fn open_folder(app: &mut PlotxApp) { if let Some(path) = rfd::FileDialog::new() - .set_title("Open a data folder (Waters MassLynx RAW, Bruker, Varian/Agilent VnmrJ, or recursive AFM/ABF2 import)") + .set_title("Open a data folder (vendor acquisitions or recursive scientific-data import)") .pick_folder() { open_folder_path(app, &path); diff --git a/crates/app/src/ui/file_dialogs/discovery.rs b/crates/app/src/ui/file_dialogs/discovery.rs index 9ef3ddf..3e998d5 100644 --- a/crates/app/src/ui/file_dialogs/discovery.rs +++ b/crates/app/src/ui/file_dialogs/discovery.rs @@ -26,7 +26,7 @@ pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { .extension() .and_then(|value| value.to_str()) .unwrap_or(""); - let supported_extension = ["abf", "spm", "pfc", "rasx", "vms"] + let supported_extension = ["abf", "spm", "pfc", "rasx", "vms", "wiff"] .iter() .any(|supported| extension.eq_ignore_ascii_case(supported)); let recognized_raw = @@ -92,4 +92,22 @@ mod tests { assert_eq!(found, vec![dataset]); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn folder_scan_discovers_only_the_primary_wiff_file() { + let root = + std::env::temp_dir().join(format!("plotx-wiff-discovery-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let wiff = root.join("sample.WIFF"); + std::fs::write(&wiff, b"container").unwrap(); + std::fs::write(root.join("sample.WIFF.scan"), b"scans").unwrap(); + std::fs::write(root.join("sample.wiff2"), b"wiff2").unwrap(); + std::fs::write(root.join("sample.timeseries.data"), b"data").unwrap(); + + let mut found = Vec::new(); + collect_data_files(&root, &mut found); + + assert_eq!(found, vec![wiff]); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/app/src/ui/file_dialogs/origin.rs b/crates/app/src/ui/file_dialogs/origin.rs index b0182f2..c561cdd 100644 --- a/crates/app/src/ui/file_dialogs/origin.rs +++ b/crates/app/src/ui/file_dialogs/origin.rs @@ -21,7 +21,8 @@ pub(super) const ORIGIN_PROJECT_FILTER_LABEL: &str = "Origin projects (experimental: OPJ import; OPJU recognition only)"; pub(super) const ORIGIN_PROJECT_FILTER_EXTENSIONS: &[&str] = &["opj", "opju"]; pub(super) const OPEN_FILE_FILTER_EXTENSIONS: &[&str] = &[ - "mzML", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj", + "mzML", "wiff", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", + "opj", ]; const ORIGIN_MEDIA_TYPE: &str = "application/x-origin-project"; diff --git a/crates/app/src/ui/file_dialogs/origin_tests.rs b/crates/app/src/ui/file_dialogs/origin_tests.rs index b7950ed..c581b38 100644 --- a/crates/app/src/ui/file_dialogs/origin_tests.rs +++ b/crates/app/src/ui/file_dialogs/origin_tests.rs @@ -118,6 +118,7 @@ fn origin_import_filter_retains_tables_and_adds_experimental_projects() { fn origin_supported_file_filter_excludes_recognition_only_opju() { assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"opj")); assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"mzML")); + assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"wiff")); assert!(!OPEN_FILE_FILTER_EXTENSIONS.contains(&"opju")); } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 79218fc..f23d078 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -605,6 +605,17 @@ fn text_report(report: &InspectionReport) -> String { ephys.protocol.as_deref().unwrap_or("unknown") )); } + if let Some(mass_spec) = &report.mass_spectrometry { + if let Some(instrument) = &mass_spec.instrument { + lines.push(format!("mass_spec.instrument: {instrument}")); + } + lines.push(format!("mass_spec.streams: {}", mass_spec.stream_count)); + lines.push(format!("mass_spec.scans: {}", mass_spec.ms_scan_count)); + lines.push(format!( + "mass_spec.chromatograms: {}", + mass_spec.chromatograms.join(", ") + )); + } if let Some(xrd) = &report.xrd { lines.push(format!("xrd.points: {}", xrd.point_count)); lines.push(format!( @@ -728,6 +739,43 @@ mod tests { assert!(parse(&["plotx-cli", "batch", "workflow.json"]).is_err()); } + #[test] + fn text_inspection_includes_mass_spectrometry_statistics() { + let report = InspectionReport { + schema: plotx_core::workflow::INSPECTION_SCHEMA, + format: "sciex-wiff".to_owned(), + provenance: plotx_core::workflow::ProvenanceReport { + selected_path: "sample.wiff".into(), + data_path: "sample.wiff".into(), + parameter_paths: Vec::new(), + companion_paths: vec!["sample.wiff.scan".into()], + }, + dimension: plotx_core::workflow::DimensionReport { + count: 3, + shape: vec![2, 42, 1], + }, + domain: "mass_spectrometry".to_owned(), + warnings: Vec::new(), + electrophysiology: None, + afm: None, + mass_spectrometry: Some(plotx_core::workflow::MassSpecReport { + instrument: Some("SCIEX TripleTOF 6600".to_owned()), + stream_count: 2, + ms_scan_count: 42, + chromatograms: vec!["total ion current chromatogram".to_owned()], + }), + xrd: None, + xps: None, + }; + + let output = text_report(&report); + + assert!(output.contains("format: sciex-wiff")); + assert!(output.contains("mass_spec.streams: 2")); + assert!(output.contains("mass_spec.scans: 42")); + assert!(output.contains("mass_spec.chromatograms: total ion current chromatogram")); + } + #[test] fn workflow_errors_map_to_stable_exit_categories() { let status = fail(WorkflowError::FigureUnavailable("NMR 1D")); diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index 6f46cd8..0df3457 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -196,6 +196,19 @@ impl Dataset { Self::Afm(_) => None, Self::MassSpec(data) => { let stream = data.run.stream(data.active_stream)?; + let chromatogram = + super::mass_spec_tic::points_for_stream_tic(&data.run, data.active_stream); + if let Some(points) = chromatogram { + let (xs, ys): (Vec<_>, Vec<_>) = points + .into_iter() + .map(|[time, value]| (time, value)) + .unzip(); + return Some(Trace1d { + xs, + ys, + x_reversed: false, + }); + } Some(Trace1d { xs: stream .spectra diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index ea9bf39..38f9f7f 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -1,3 +1,4 @@ +use super::mass_spec_tic::points_for_stream_tic; use super::{ DatasetId, DatasetLineage, FieldCatalog, FieldId, mass_spec_xic::{ExtractedIonChromatogram, IonChromatogramId, xic_key, xic_title}, @@ -460,15 +461,18 @@ impl MassSpecDataset { let stream_id = stream.id; let stream_label = stream_display_label(stream); if self.field_catalog.id_for_key(&stream_tic_key(stream_id)) == Some(id) { + let chromatogram_points = points_for_stream_tic(&self.run, stream_id); return Some(( format!("{stream_label} TIC"), "Retention time (min)", "Total ion current".to_owned(), - stream - .spectra - .iter() - .map(|scan| [scan.retention_time_min, scan.tic]) - .collect(), + chromatogram_points.unwrap_or_else(|| { + stream + .spectra + .iter() + .map(|scan| [scan.retention_time_min, scan.tic]) + .collect() + }), false, )); } diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index 1f4decb..a2e2bf7 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -3,6 +3,7 @@ use crate::actions::Action; use crate::state::{ AxisRange, Dataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesSource, ToolGroup, }; +use plotx_io::{ChromatogramChannel, ChromatogramChannelId}; #[test] fn dynamic_catalog_and_stable_selection_follow_stream_identity() { @@ -86,6 +87,48 @@ fn mean_extraction_averages_missing_profile_coordinates_as_zero() { assert_eq!(points, [[10.0, 1.0], [20.0, 4.5], [30.0, 0.5]]); } +#[test] +fn stream_tic_prefers_bound_chromatogram_points() { + let mut run = sample_mass_spec_run(); + run.chromatograms.push(ChromatogramChannel { + id: ChromatogramChannelId("tic:bound".to_owned()), + kind: ChromatogramKind::Unknown, + source_stream: Some(AcquisitionStreamId::new(3)), + coordinate: None, + description: "Total ion current".to_owned(), + unit: "cps".to_owned(), + time_min: vec![0.0, 2.0], + values: vec![11.0, 22.0], + }); + let dataset = MassSpecDataset::load(run); + let field = dataset + .field_catalog + .id_for_key(&stream_tic_key(AcquisitionStreamId::new(3))) + .expect("stream TIC field"); + let (_, _, _, points, stick) = dataset.field_values(field).expect("TIC values"); + assert!(!stick); + assert_eq!(points, [[0.0, 11.0], [2.0, 22.0]]); +} + +#[test] +fn displayed_mass_spec_trace_uses_bound_tic_channel() { + let mut run = sample_mass_spec_run(); + run.chromatograms.push(ChromatogramChannel { + id: ChromatogramChannelId("tic:rendered".to_owned()), + kind: ChromatogramKind::Unknown, + source_stream: Some(AcquisitionStreamId::new(3)), + coordinate: None, + description: "Total ion current".to_owned(), + unit: "cps".to_owned(), + time_min: vec![0.0, 1.0], + values: vec![100.0, 200.0], + }); + let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(run))); + let trace = dataset.displayed_trace(None).expect("mass-spec trace"); + assert_eq!(trace.xs, [0.0, 1.0]); + assert_eq!(trace.ys, [100.0, 200.0]); +} + #[test] fn stream_and_retention_time_selection_retarget_all_linked_plots() { let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(sample_mass_spec_run()))); diff --git a/crates/core/src/state/mass_spec_tic.rs b/crates/core/src/state/mass_spec_tic.rs new file mode 100644 index 0000000..418104e --- /dev/null +++ b/crates/core/src/state/mass_spec_tic.rs @@ -0,0 +1,23 @@ +use plotx_io::{AcquisitionStreamId, MassSpecRun}; + +pub(crate) fn points_for_stream_tic( + run: &MassSpecRun, + stream_id: AcquisitionStreamId, +) -> Option> { + let channel = run + .chromatograms + .iter() + .find(|channel| channel.source_stream == Some(stream_id))?; + if channel.time_min.len() != channel.values.len() { + return None; + } + Some( + channel + .time_min + .iter() + .copied() + .zip(channel.values.iter().copied()) + .map(|(time, value)| [time, value]) + .collect(), + ) +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 3c80360..8b611a4 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -82,6 +82,7 @@ mod linefit; mod mass_spec; mod mass_spec_app; mod mass_spec_ranges; +mod mass_spec_tic; mod mass_spec_xic; mod multiplet; mod nmr_integrals; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 413cc45..bcdb785 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -26,3 +26,5 @@ tiff.workspace = true sha2.workspace = true memmap2.workspace = true tempfile.workspace = true +cfb = "0.14" +byteorder = "1.5" diff --git a/crates/io/src/format.rs b/crates/io/src/format.rs index f9ad52f..3679553 100644 --- a/crates/io/src/format.rs +++ b/crates/io/src/format.rs @@ -35,6 +35,7 @@ pub enum AfmFormat { pub enum MassSpectrometryFormat { WatersMassLynxRaw, MzMl, + SciexWiff, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -87,6 +88,7 @@ impl DataFormat { "waters-masslynx-raw" } Self::MassSpectrometry(MassSpectrometryFormat::MzMl) => "mzml", + Self::MassSpectrometry(MassSpectrometryFormat::SciexWiff) => "sciex-wiff", Self::Xrd(XrdFormat::RigakuRasx) => "rigaku-rasx", Self::Xrd(XrdFormat::RigakuRaw) => "rigaku-raw-fi", Self::Xrd(XrdFormat::RigakuProfile) => "rigaku-profile", diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c96735e..a49191d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -12,6 +12,7 @@ pub mod mzml; pub mod nanoscope; mod nmr_origin; pub mod origin; +pub mod sciex_wiff; pub mod varian; pub mod waters; pub mod xlsx; @@ -659,6 +660,12 @@ pub enum IoError { #[error("invalid or unsupported mzML: {0}")] InvalidMzMl(String), + #[error("invalid or unsupported SCIEX WIFF: {0}")] + InvalidSciexWiff(String), + + #[error("unsupported SCIEX WIFF: {0}")] + UnsupportedSciexWiff(String), + #[error("invalid XPS data: {0}")] InvalidXps(String), @@ -702,6 +709,11 @@ pub fn detect_format(path: impl AsRef) -> Result { .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); + let lower_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); match ext.as_str() { "rasx" => Ok(DataFormat::Xrd(XrdFormat::RigakuRasx)), "raw" if xrd::is_rigaku_raw(path) => Ok(DataFormat::Xrd(XrdFormat::RigakuRaw)), @@ -722,6 +734,15 @@ pub fn detect_format(path: impl AsRef) -> Result { "jdf" => Ok(DataFormat::Nmr(NmrFormat::JeolDelta)), "dx" | "jdx" | "jcamp" => Ok(DataFormat::Nmr(NmrFormat::JcampDx1D)), "mzml" => Ok(DataFormat::MassSpectrometry(MassSpectrometryFormat::MzMl)), + "wiff" => Ok(DataFormat::MassSpectrometry( + MassSpectrometryFormat::SciexWiff, + )), + "wiff2" | "data" if ext == "wiff2" || lower_name.ends_with(".timeseries.data") => { + Err(IoError::UnsupportedSciexWiff( + "SCIEX WIFF2 and timeseries.data are not currently supported; convert the acquisition to mzML before opening it in PlotX" + .to_owned(), + )) + } // Fall back to a content sniff so extensionless or mislabelled files // are still recognised by their magic bytes. _ if abf2::is_abf2(path) => { @@ -729,7 +750,7 @@ pub fn detect_format(path: impl AsRef) -> Result { } _ if jeol::is_jdf(path) => Ok(DataFormat::Nmr(NmrFormat::JeolDelta)), _ => Err(IoError::Unsupported(format!( - "unrecognised path {}: expected mzML, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser or pdata, or a Varian/Agilent VnmrJ .fid directory", + "unrecognised path {}: expected mzML, legacy SCIEX .wiff, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser or pdata, or a Varian/Agilent VnmrJ .fid directory", path.display() ))), } @@ -753,6 +774,7 @@ pub fn load_path(path: impl AsRef) -> Result { waters::load(path) } DataFormat::MassSpectrometry(MassSpectrometryFormat::MzMl) => mzml::load(path), + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) => sciex_wiff::load(path), DataFormat::Xrd(XrdFormat::RigakuRasx) => xrd::load_rasx(path), DataFormat::Xrd(XrdFormat::RigakuRaw) => xrd::load_raw(path), DataFormat::Xrd(XrdFormat::RigakuProfile) => xrd::load_profile(path), diff --git a/crates/io/src/sciex_wiff.rs b/crates/io/src/sciex_wiff.rs new file mode 100644 index 0000000..3e4c940 --- /dev/null +++ b/crates/io/src/sciex_wiff.rs @@ -0,0 +1,789 @@ +#![allow(dead_code)] +use crate::{ + Acquisition, AcquisitionStream, AcquisitionStreamId, DataFormat, IoError, LoadResult, + LoadWarning, LoadWarningCode, MassSpecRun, MassSpectrometryFormat, MassSpectrum, Polarity, + Precursor, Provenance, SpectrumId, SpectrumRepresentation, StreamRole, +}; +use byteorder::{ByteOrder, LittleEndian}; +use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +#[path = "sciex_wiff_scan.rs"] +mod scan; +use scan::{companion_path, decode_scan_block}; +#[path = "sciex_wiff_tic.rs"] +mod tic; +struct StreamBuilder { + id: AcquisitionStreamId, + experiment_index: u32, + ms_level: u8, + polarity: Polarity, + low_mz: f64, + high_mz: f64, + spectra: Vec, +} +#[rustfmt::skip] +#[derive(Clone, Copy, Debug, PartialEq)] enum SourcePolarity { Positive, Negative } +#[rustfmt::skip] +#[derive(Clone, Copy, Debug, PartialEq)] enum ScanMode { Centroid, Profile } +#[rustfmt::skip] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] enum Activation { HCD, MPID, ETD, CID, ECD, IRMPD, PD, PQD, UVPD, SID, EThcD } +#[rustfmt::skip] +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Debug, PartialEq)] enum Analyzer { TOFMS, TQMS } +#[derive(Clone, Debug, Default)] +struct PrecursorInfo { + selected_mz: Option, + target_mz: Option, + isolation_width: Option, + charge: Option, + collision_energy: Option, + activation: Option, +} +#[derive(Clone, Debug)] +struct SpectrumRecord { + index: usize, + scan_number: u32, + native_id: String, + ms_level: u32, + polarity: Option, + scan_mode: Option, + retention_time_sec: f64, + total_ion_current: Option, + precursor: Option, + mz: Vec, + intensity: Vec, + analyzer: Option, + acquisition_event_id: Option, + filter: Option, + base_peak_mz: Option, + base_peak_intensity: Option, + low_mz: Option, + high_mz: Option, + ion_injection_time_ms: Option, + inv_mobility: Option, + faims_cv: Option, + inv_mobility_per_peak: Option>, + extra: BTreeMap, +} +#[derive(Clone, Debug)] +struct IdxRecord { + scan_offset: u32, + scan_size: u32, + acquisition_time_ms: f64, + legacy_time_min: f32, + tic: f64, + declared_ms_level: u32, + experiment_index: usize, + cycle_index: usize, +} +#[derive(Clone, Copy)] +struct Calibration { + slope: f64, + intercept: f64, +} +impl Calibration { + fn apply(self, value: u32) -> f64 { + self.intercept + self.slope * value as f64 + } +} +fn list_samples(path: &Path) -> Result, IoError> { + let file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let compound = cfb::CompoundFile::open(file).map_err(|e| invalid(e.to_string()))?; + let mut names = compound + .read_storage("SampleSubtree") + .map_err(|e| invalid(e.to_string()))? + .filter(|entry| entry.is_storage()) + .map(|entry| entry.name().to_owned()) + .collect::>(); + names.sort_by_key(|name| { + name.strip_prefix("Sample") + .and_then(|n| n.parse::().ok()) + .unwrap_or(u64::MAX) + }); + Ok(names) +} +#[allow(clippy::chunks_exact_to_as_chunks)] +fn read_idx(path: &Path, sample: &str) -> Result, IoError> { + let mut file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let mut compound = cfb::CompoundFile::open(&mut file).map_err(|e| invalid(e.to_string()))?; + let mut data = Vec::new(); + compound + .open_stream(format!("SampleSubtree/{sample}/Idx")) + .map_err(|e| invalid(e.to_string()))? + .read_to_end(&mut data) + .map_err(|e| invalid(e.to_string()))?; + const HEADER: usize = 32; + const SIZE: usize = 54; + let body = data + .get(HEADER..) + .ok_or_else(|| invalid(format!("WIFF sample {sample} has a truncated Idx header")))?; + if body.is_empty() || body.len() % SIZE != 0 { + return Err(invalid(format!( + "WIFF sample {sample} has an unsupported Idx record layout" + ))); + } + let record_count = body.len() / SIZE; + const EXPERIMENTS: usize = 11; + let experiments = if record_count == 1 { 1 } else { EXPERIMENTS }; + if record_count < experiments || !record_count.is_multiple_of(experiments) { + return Err(invalid(format!( + "WIFF sample {sample} does not contain complete 11-slot acquisition cycles" + ))); + } + let mut out = Vec::with_capacity(record_count); + for (index, chunk) in body.chunks_exact(SIZE).enumerate() { + let acquisition_time_ms = LittleEndian::read_f64(&chunk[8..16]); + let tic = LittleEndian::read_f64(&chunk[18..26]); + if !acquisition_time_ms.is_finite() + || acquisition_time_ms < 0.0 + || !tic.is_finite() + || tic < 0.0 + { + return Err(invalid(format!( + "WIFF sample {sample} contains invalid Idx time or TIC at record {index}" + ))); + } + out.push(IdxRecord { + scan_offset: LittleEndian::read_u32(&chunk[..4]), + scan_size: LittleEndian::read_u32(&chunk[4..8]), + acquisition_time_ms, + legacy_time_min: LittleEndian::read_f32(&chunk[12..16]), + tic, + declared_ms_level: u32::from(LittleEndian::read_u16(&chunk[16..18])), + experiment_index: index % experiments, + cycle_index: index / experiments, + }); + } + for slot in 0..EXPERIMENTS { + let records = out.iter().skip(slot).step_by(EXPERIMENTS); + let mut previous = f64::NEG_INFINITY; + for record in records { + if record.acquisition_time_ms < previous { + return Err(invalid(format!( + "WIFF sample {sample} has non-monotonic acquisition time in experiment {}", + slot + 1 + ))); + } + previous = record.acquisition_time_ms; + } + } + if out.is_empty() { + return Err(invalid(format!( + "WIFF sample {sample} contains no index records" + ))); + } + Ok(out) +} +fn read_stream(path: &Path, stream_path: &str) -> Result>, IoError> { + let mut file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let mut compound = cfb::CompoundFile::open(&mut file).map_err(|e| invalid(e.to_string()))?; + let Ok(mut stream) = compound.open_stream(stream_path) else { + return Ok(None); + }; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|e| invalid(e.to_string()))?; + Ok(Some(bytes)) +} +fn validate_auxiliary_layout( + path: &Path, + sample: &str, + records: &[IdxRecord], +) -> Result<(), IoError> { + let cycles = records + .iter() + .map(|record| record.cycle_index) + .max() + .map_or(0, |value| value + 1); + for (name, stride) in [("Itc", 88_usize), ("DDERealTimeData", 320_usize)] { + let stream_path = format!("SampleSubtree/{sample}/{name}"); + if let Some(bytes) = read_stream(path, &stream_path)? { + let expected = 32_usize + .checked_add(cycles.checked_mul(stride).ok_or_else(|| { + invalid(format!( + "WIFF sample {sample} has too many acquisition cycles" + )) + })?) + .ok_or_else(|| invalid("WIFF auxiliary stream length overflow"))?; + if bytes.len() != expected { + return Err(invalid(format!( + "WIFF sample {sample} has unsupported {name} length {} (expected {expected})", + bytes.len() + ))); + } + } + } + Ok(()) +} +fn read_calibration(path: &Path, sample: &str) -> Result, IoError> { + let mut file = File::open(path).map_err(|e| invalid(e.to_string()))?; + let mut compound = cfb::CompoundFile::open(&mut file).map_err(|e| invalid(e.to_string()))?; + let Ok(mut stream) = compound.open_stream(format!("SampleSubtree/{sample}/TOFCalibrationData")) + else { + return Ok(None); + }; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|e| invalid(e.to_string()))?; + if bytes.len() < 48 { + return Ok(None); + } + let calibration = Calibration { + slope: LittleEndian::read_f64(&bytes[32..40]), + intercept: LittleEndian::read_f64(&bytes[40..48]), + }; + Ok((calibration.slope.is_finite() && calibration.intercept.is_finite()).then_some(calibration)) +} + +fn decode_sample( + path: &Path, + sample: &str, + idx: &[IdxRecord], + calibration: Option, +) -> Result, IoError> { + let scan = std::fs::read(companion_path(path)?).map_err(|e| invalid(e.to_string()))?; + let mut out = Vec::new(); + for (i, rec) in idx.iter().enumerate() { + let base = usize::try_from(rec.scan_offset) + .map_err(|_| invalid("WIFF scan offset does not fit in memory"))?; + if rec.scan_size > 0 + && (base >= scan.len() + || base + .checked_add( + usize::try_from(rec.scan_size) + .map_err(|_| invalid("WIFF scan size overflow"))?, + ) + .is_none_or(|end| end > scan.len())) + { + return Err(invalid(format!( + "WIFF sample {sample} scan {i} exceeds the .scan payload" + ))); + } + let end_by_size = base + .checked_add( + usize::try_from(rec.scan_size).map_err(|_| invalid("WIFF scan size overflow"))?, + ) + .and_then(|value| value.checked_add(64)) + .ok_or_else(|| invalid("WIFF scan boundary overflow"))?; + let next_same_experiment = idx + .get(i + 11) + .filter(|next| next.experiment_index == rec.experiment_index) + .map(|next| usize::try_from(next.scan_offset).unwrap_or(scan.len())) + .unwrap_or(scan.len()); + if rec.scan_size > 0 && next_same_experiment < base { + return Err(invalid(format!( + "WIFF sample {sample} experiment {} has decreasing scan offsets", + rec.experiment_index + 1 + ))); + } + let end = end_by_size.min(next_same_experiment).min(scan.len()); + let (pts, _payload_start) = if rec.scan_size == 0 { + (Vec::new(), base) + } else if base >= end { + return Err(invalid(format!( + "WIFF sample {sample} scan {i} points outside the .scan payload" + ))); + } else { + decode_scan_block(&scan[base..end], base) + }; + let mut mz = Vec::new(); + let mut intensity = Vec::new(); + for p in pts { + if p.raw_intensity > 0 { + mz.push( + calibration + .as_ref() + .map_or(p.raw_mz_bin as f64, |c| c.apply(p.raw_mz_bin)), + ); + intensity.push(p.raw_intensity as f32); + } + } + out.push(SpectrumRecord { + index: i, + scan_number: (i + 1) as u32, + native_id: if idx.len() == 1 { + format!( + "file={} scan={}", + path.file_stem().and_then(|s| s.to_str()).unwrap_or(sample), + i + 1 + ) + } else { + format!( + "file={} experiment={} cycle={} scan={}", + path.file_stem().and_then(|s| s.to_str()).unwrap_or(sample), + rec.experiment_index + 1, + rec.cycle_index + 1, + i + 1 + ) + }, + ms_level: if idx.len() == 1 { + rec.declared_ms_level + } else if rec.experiment_index == 0 { + 1 + } else { + 2 + }, + polarity: calibration.map(|_| SourcePolarity::Positive), + scan_mode: None, + retention_time_sec: if idx.len() == 1 { + f64::from(rec.legacy_time_min) * 60.0 + } else if rec.acquisition_time_ms > 0.0 { + rec.acquisition_time_ms / 1000.0 + } else { + f64::from(rec.legacy_time_min) * 60.0 + }, + total_ion_current: Some(rec.tic), + precursor: None, + mz, + intensity, + analyzer: Some(Analyzer::TOFMS), + acquisition_event_id: Some( + u32::try_from(rec.experiment_index) + .map_err(|_| invalid("WIFF experiment index overflow"))?, + ), + filter: None, + base_peak_mz: None, + base_peak_intensity: None, + low_mz: None, + high_mz: None, + ion_injection_time_ms: None, + inv_mobility: None, + faims_cv: None, + inv_mobility_per_peak: None, + extra: BTreeMap::new(), + }); + } + Ok(out) +} + +struct SampleGroup { + label: String, + sample: String, +} +#[allow(clippy::chunks_exact_to_as_chunks)] +fn sample_name(path: &Path, sample: &str) -> Result { + let stream_path = format!("SampleSubtree/{sample}/SampleDABE/DATA"); + let Some(bytes) = read_stream(path, &stream_path)? else { + return Ok(sample.to_owned()); + }; + if bytes.len() >= 38 { + let byte_len = LittleEndian::read_u16(&bytes[36..38]) as usize; + let end = 38_usize.saturating_add(byte_len).min(bytes.len()); + if end > 38 { + let candidate = String::from_utf16_lossy( + &bytes[38..end] + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .take(byte_len / 2) + .collect::>(), + ); + let candidate = candidate.trim_matches('\0').trim(); + if candidate.len() >= 2 && candidate.chars().all(|c| c.is_ascii_graphic() || c == ' ') { + return Ok(candidate.to_owned()); + } + } + } + let mut best = String::new(); + let mut current = String::new(); + for pair in bytes.chunks_exact(2) { + let value = u16::from_le_bytes([pair[0], pair[1]]); + if value == 0 { + if current.len() > best.len() { + best.clone_from(¤t); + } + current.clear(); + } else if (0x20..=0x7e).contains(&value) { + current.push(value as u8 as char); + } else if !current.is_empty() { + if current.len() > best.len() { + best.clone_from(¤t); + } + current.clear(); + } + } + if current.len() > best.len() { + best = current; + } + let label = best.trim().to_owned(); + Ok(if label.len() >= 2 { + label + } else { + sample.to_owned() + }) +} + +fn sample_groups(path: &Path, samples: &[String]) -> Result, IoError> { + let mut counts = BTreeMap::::new(); + samples + .iter() + .map(|sample| { + let base = sample_name(path, sample)?; + let count = counts.entry(base.clone()).or_default(); + *count += 1; + let label = if *count == 1 { + base + } else { + format!("{base} #{}", *count) + }; + Ok(SampleGroup { + label, + sample: sample.clone(), + }) + }) + .collect::, IoError>>() + .map(|mut groups| { + let mut bases = BTreeMap::::new(); + for group in &groups { + *bases + .entry( + group + .label + .split(" #") + .next() + .unwrap_or(&group.label) + .to_owned(), + ) + .or_default() += 1; + } + for group in &mut groups { + if !group.label.contains(" #") && bases.get(&group.label).copied().unwrap_or(0) > 1 + { + group.label.push_str(" #1"); + } + } + groups + }) +} + +pub fn load(path: &Path) -> Result { + let scan_path = companion_path(path)?; + if !scan_path.is_file() { + return Err(invalid(format!( + "paired .wiff.scan file is missing: {}", + scan_path.display() + ))); + } + + let samples = list_samples(path)?; + if samples.is_empty() { + return Err(invalid("the WIFF container contains no samples")); + } + + let mut metadata = BTreeMap::new(); + metadata.insert("source format".to_owned(), "SCIEX WIFF".to_owned()); + let groups = sample_groups(path, &samples)?; + metadata.insert("sample count".to_owned(), groups.len().to_string()); + metadata.insert( + "samples".to_owned(), + groups + .iter() + .map(|group| group.label.as_str()) + .collect::>() + .join(", "), + ); + let multiple_samples = groups.len() > 1; + let mut streams = Vec::new(); + let mut chromatograms = Vec::new(); + let mut import_warnings = Vec::new(); + let mut instruments = BTreeSet::new(); + let mut next_stream_id = 1_u64; + for (sample_index, group) in groups.iter().enumerate() { + let idx = read_idx(path, &group.sample)?; + validate_auxiliary_layout(path, &group.sample, &idx)?; + let calibration = read_calibration(path, &group.sample)?; + if sample_index == 0 { + metadata.insert("source file format".to_owned(), "SCIEX WIFF".to_owned()); + metadata.insert( + "native ID format".to_owned(), + "file=... scan=...".to_owned(), + ); + metadata.insert( + "reader".to_owned(), + format!("{} {}", "PlotX", "native WIFF parser"), + ); + } + instruments.insert("SCIEX instrument model".to_owned()); + let decoded = decode_sample(path, &group.sample, &idx, calibration)?; + let built_streams = build_streams( + decoded, + &group.label, + &mut next_stream_id, + &mut import_warnings, + )?; + if built_streams.is_empty() { + return Err(invalid(format!( + "WIFF sample {} contains no spectra", + group.label + ))); + } + let source_stream = built_streams + .iter() + .find(|stream| { + stream + .source_native_id + .as_deref() + .is_some_and(|id| id.contains("experiment=1")) + }) + .map(|stream| stream.id); + streams.extend(built_streams.iter().cloned()); + let tic_records = idx + .iter() + .map(|record| { + ( + record.experiment_index, + record.acquisition_time_ms, + record.legacy_time_min, + record.tic, + ) + }) + .collect::>(); + chromatograms.extend(tic::channels( + &tic_records, + &built_streams, + &group.label, + multiple_samples, + )?); + let _ = source_stream; + } + let instrument = + (!instruments.is_empty()).then(|| instruments.into_iter().collect::>().join(", ")); + let run = MassSpecRun { + source: path.to_string_lossy().into_owned(), + metadata, + instrument, + streams, + chromatograms, + import_warnings: import_warnings.clone(), + }; + run.validate().map_err(invalid)?; + + let mut identity = crate::AcquisitionIdentity::from_path(path); + if let [group] = groups.as_slice() { + identity.subject = Some(group.label.clone()); + } else { + identity.acquisition = Some(format!("{} samples", groups.len())); + } + + Ok(LoadResult::new( + Acquisition::MassSpec(Box::new(run)), + identity, + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff), + Provenance { + selected_path: path.to_owned(), + data_path: path.to_owned(), + parameter_paths: Vec::new(), + companion_paths: vec![scan_path], + }, + import_warnings + .into_iter() + .map(|message| LoadWarning { + code: LoadWarningCode::InvalidMetadata, + message, + path: Some(path.to_owned()), + }) + .collect(), + )) +} + +fn build_streams( + records: Vec, + sample: &str, + next_stream_id: &mut u64, + warnings: &mut Vec, +) -> Result, IoError> { + let mut builders = BTreeMap::<(u32, u8), StreamBuilder>::new(); + for record in records { + if record.mz.is_empty() + && record.intensity.is_empty() + && record.acquisition_event_id.is_none() + { + warnings.push(format!( + "WIFF sample {sample} scan {} contained no decoded points and was skipped", + record.native_id + )); + continue; + } + let ms_level = u8::try_from(record.ms_level).map_err(|_| { + invalid(format!( + "scan {} has an unsupported MS level", + record.native_id + )) + })?; + if ms_level == 0 { + return Err(invalid(format!( + "scan {} has an invalid MS level of zero", + record.native_id + ))); + } + let polarity = map_polarity(record.polarity); + let polarity_key = match polarity { + Polarity::Unknown => 0, + Polarity::Positive => 1, + Polarity::Negative => 2, + }; + let experiment_index = record + .acquisition_event_id + .unwrap_or(record.index as u32 % 11); + let builder = match builders.entry((experiment_index, polarity_key)) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let id = AcquisitionStreamId::new(*next_stream_id); + *next_stream_id = next_stream_id.checked_add(1).ok_or_else(|| { + invalid("the WIFF container has too many acquisition streams") + })?; + entry.insert(StreamBuilder { + id, + experiment_index, + ms_level, + polarity, + low_mz: f64::INFINITY, + high_mz: f64::NEG_INFINITY, + spectra: Vec::new(), + }) + } + }; + let spectrum = convert_spectrum(record, ms_level, polarity)?; + for &mz in &spectrum.mz { + builder.low_mz = builder.low_mz.min(mz); + builder.high_mz = builder.high_mz.max(mz); + } + builder.spectra.push(spectrum); + } + + if builders.is_empty() { + return Err(invalid(format!( + "WIFF sample {sample} contains no decoded spectra" + ))); + } + + let single_builder = builders.len() == 1; + Ok(builders + .into_values() + .map(|builder| { + let polarity = polarity_label(builder.polarity); + AcquisitionStream { + id: builder.id, + source_native_id: Some(format!( + "sample={sample} experiment={} ms_level={} polarity={polarity}", + builder.experiment_index + 1, + builder.ms_level + )), + source_label: Some(if single_builder { + format!("{sample} - MS{} {polarity}", builder.ms_level) + } else { + format!( + "{sample} - Experiment {} MS{} {polarity}", + builder.experiment_index + 1, + builder.ms_level + ) + }), + role: StreamRole::Primary, + acquisition_range: (builder.low_mz <= builder.high_mz) + .then_some([builder.low_mz, builder.high_mz]), + spectra: builder.spectra, + } + }) + .collect()) +} + +fn convert_spectrum( + record: SpectrumRecord, + ms_level: u8, + polarity: Polarity, +) -> Result { + if record.mz.len() != record.intensity.len() { + return Err(invalid(format!( + "scan {} has {} m/z values but {} intensity values", + record.native_id, + record.mz.len(), + record.intensity.len() + ))); + } + if record.mz.is_empty() && record.acquisition_event_id.is_none() { + return Err(invalid(format!( + "scan {} contains no decoded points", + record.native_id + ))); + } + let intensity: Vec = record + .intensity + .iter() + .map(|&value| f64::from(value)) + .collect(); + let tic = record + .total_ion_current + .filter(|value| value.is_finite() && *value >= 0.0) + .unwrap_or_else(|| intensity.iter().copied().sum::()); + let base_peak = intensity + .iter() + .enumerate() + .filter(|(_, value)| value.is_finite() && **value >= 0.0) + .max_by(|(_, left), (_, right)| left.total_cmp(right)); + let (base_peak_mz, base_peak_intensity) = base_peak.map_or((None, None), |(index, value)| { + (record.mz.get(index).copied(), Some(*value)) + }); + let precursor = record.precursor.and_then(|source| { + let selected_mz = source.selected_mz.or(source.target_mz)?; + let half_width = source.isolation_width.map(|width| width / 2.0); + Some(Precursor { + selected_mz, + charge: source.charge, + isolation_window_lower_offset: half_width, + isolation_window_upper_offset: half_width, + collision_energy: source.collision_energy, + activation_method: source.activation.map(activation_label), + }) + }); + + Ok(MassSpectrum { + id: SpectrumId::new(record.scan_number.into()), + source_native_id: Some(record.native_id), + retention_time_min: record.retention_time_sec / 60.0, + ms_level, + polarity, + representation: match record.scan_mode { + Some(ScanMode::Centroid) => SpectrumRepresentation::Centroid, + Some(ScanMode::Profile) => SpectrumRepresentation::Profile, + None => SpectrumRepresentation::Unknown, + }, + mz: record.mz, + intensity, + tic, + base_peak_mz, + base_peak_intensity, + precursor, + }) +} +fn map_polarity(polarity: Option) -> Polarity { + match polarity { + Some(SourcePolarity::Positive) => Polarity::Positive, + Some(SourcePolarity::Negative) => Polarity::Negative, + None => Polarity::Unknown, + } +} + +fn polarity_label(polarity: Polarity) -> &'static str { + match polarity { + Polarity::Positive => "positive", + Polarity::Negative => "negative", + Polarity::Unknown => "unknown", + } +} + +fn activation_label(activation: Activation) -> String { + format!("{activation:?}") +} + +fn invalid(message: impl Into) -> IoError { + IoError::InvalidSciexWiff(message.into()) +} + +#[cfg(test)] +#[path = "sciex_wiff_tests.rs"] +mod tests; diff --git a/crates/io/src/sciex_wiff_scan.rs b/crates/io/src/sciex_wiff_scan.rs new file mode 100644 index 0000000..f870d4d --- /dev/null +++ b/crates/io/src/sciex_wiff_scan.rs @@ -0,0 +1,116 @@ +use crate::IoError; +use byteorder::{ByteOrder, LittleEndian}; +use std::path::{Path, PathBuf}; + +pub(super) fn companion_path(path: &Path) -> Result { + let mut name = path + .file_name() + .ok_or_else(|| IoError::InvalidSciexWiff("the WIFF path has no filename".to_owned()))? + .to_os_string(); + name.push(".scan"); + let mut companion = path.to_owned(); + companion.set_file_name(name); + Ok(companion) +} + +#[derive(Clone, Copy)] +pub(super) struct ScanPoint { + pub raw_mz_bin: u32, + pub raw_intensity: u32, +} + +pub(super) fn decode_payload(payload: &[u8]) -> Vec { + let mut points = Vec::new(); + let mut mz = 0_u32; + let mut i = 0; + while i < payload.len() { + let b = payload[i]; + if b == 0xff && payload.get(i..i + 4) == Some(&[0xff; 4]) { + break; + } + match b { + 0..=0x7f => { + mz = mz.wrapping_add(b as u32); + i += 1; + } + 0x80..=0xfb => { + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: (b & 0x7f) as u32, + }); + i += 1; + } + 0xfc => { + if i + 1 >= payload.len() { + break; + } + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: payload[i + 1] as u32, + }); + i += 2; + } + 0xfd => { + if i + 2 >= payload.len() { + break; + } + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: LittleEndian::read_u16(&payload[i + 1..i + 3]) as u32, + }); + i += 3; + } + 0xfe => { + if i + 3 >= payload.len() { + break; + } + let value = payload[i + 1] as u32 + | (payload[i + 2] as u32) << 8 + | (payload[i + 3] as u32) << 16; + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: value, + }); + i += 4; + } + 0xff => { + if i + 4 >= payload.len() { + break; + } + points.push(ScanPoint { + raw_mz_bin: mz, + raw_intensity: LittleEndian::read_u32(&payload[i + 1..i + 5]), + }); + i += 5; + } + } + } + points +} + +pub(super) fn decode_scan_block(block: &[u8], absolute_base: usize) -> (Vec, usize) { + let terminator = block.windows(4).position(|window| window == [0xff; 4]); + let mut starts = vec![56.min(block.len())]; + if let Some(position) = terminator { + starts.push(position.saturating_add(8).min(block.len())); + starts.push(position.saturating_add(4).min(block.len())); + } + starts.push(0); + let mut best = Vec::new(); + let mut best_start = 0; + for start in starts { + if start >= block.len() { + continue; + } + let stop = block[start..] + .windows(4) + .position(|window| window == [0xff; 4]) + .map_or(block.len(), |position| start + position); + let points = decode_payload(&block[start..stop]); + if points.len() > best.len() { + best = points; + best_start = absolute_base + start; + } + } + (best, best_start) +} diff --git a/crates/io/src/sciex_wiff_tests.rs b/crates/io/src/sciex_wiff_tests.rs new file mode 100644 index 0000000..584dad9 --- /dev/null +++ b/crates/io/src/sciex_wiff_tests.rs @@ -0,0 +1,372 @@ +use super::*; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::PathBuf; + +fn source_spectrum() -> SpectrumRecord { + SpectrumRecord { + index: 4, + scan_number: 5, + native_id: "file=fixture scan=5".to_owned(), + ms_level: 2, + polarity: Some(SourcePolarity::Positive), + scan_mode: Some(ScanMode::Centroid), + analyzer: Some(Analyzer::TOFMS), + acquisition_event_id: None, + filter: None, + retention_time_sec: 90.0, + total_ion_current: None, + base_peak_mz: None, + base_peak_intensity: None, + low_mz: None, + high_mz: None, + ion_injection_time_ms: None, + inv_mobility: None, + faims_cv: None, + precursor: Some(PrecursorInfo { + selected_mz: Some(445.34), + target_mz: Some(445.35), + isolation_width: Some(2.0), + charge: Some(2), + collision_energy: Some(30.0), + activation: Some(Activation::CID), + }), + mz: vec![100.0, 250.0, 600.0], + intensity: vec![3.0, 11.0, 7.0], + inv_mobility_per_peak: None, + extra: BTreeMap::new(), + } +} + +fn write_synthetic_pair(path: &Path, samples: &[(&str, u32, f32, f64)]) -> PathBuf { + let file = std::fs::File::create(path).unwrap(); + let mut compound = cfb::CompoundFile::create(file).unwrap(); + compound.create_storage("SampleSubtree").unwrap(); + let mut scan = vec![0_u8; samples.len() * 100]; + for (index, (sample, ms_level, time_min, tic)) in samples.iter().enumerate() { + compound + .create_storage(format!("SampleSubtree/{sample}")) + .unwrap(); + let offset = index * 100; + let mut idx = vec![0_u8; 32 + 54]; + idx[32..36].copy_from_slice(&u32::try_from(offset).unwrap().to_le_bytes()); + idx[36..40].copy_from_slice(&100_u32.to_le_bytes()); + idx[44..48].copy_from_slice(&time_min.to_le_bytes()); + idx[48..50].copy_from_slice(&u16::try_from(*ms_level).unwrap().to_le_bytes()); + idx[50..58].copy_from_slice(&tic.to_le_bytes()); + compound + .create_stream(format!("SampleSubtree/{sample}/Idx")) + .unwrap() + .write_all(&idx) + .unwrap(); + scan[offset + 56..offset + 64] + .copy_from_slice(&[100, 0x85, 10, 0x89, 0xff, 0xff, 0xff, 0xff]); + } + compound.flush().unwrap(); + drop(compound); + + let mut scan_path = path.to_owned(); + let mut name = path.file_name().unwrap().to_os_string(); + name.push(".scan"); + scan_path.set_file_name(name); + std::fs::write(&scan_path, scan).unwrap(); + scan_path +} + +#[test] +fn detects_wiff_extension_case_insensitively() { + for path in ["run.wiff", "run.WIFF", "run.WiFf"] { + assert_eq!( + crate::detect_format(path).unwrap(), + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) + ); + } + assert_eq!( + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff).as_str(), + "sciex-wiff" + ); +} + +#[test] +fn rejects_wiff2_and_timeseries_with_conversion_guidance() { + for path in ["run.wiff2", "run.WIFF2", "run.timeseries.data"] { + let error = crate::detect_format(path).unwrap_err().to_string(); + assert!(error.contains("not currently supported"), "{error}"); + assert!(error.contains("mzML"), "{error}"); + } +} + +#[test] +fn requires_the_paired_scan_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing.wiff"); + std::fs::write(&path, b"not inspected before companion check").unwrap(); + + let error = load(&path).unwrap_err().to_string(); + + assert!( + error.contains("paired .wiff.scan file is missing"), + "{error}" + ); + assert!(error.contains("missing.wiff.scan"), "{error}"); +} + +#[test] +fn reports_a_corrupt_wiff_container() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("corrupt.wiff"); + std::fs::write(&path, b"not an OLE container").unwrap(); + std::fs::write(directory.path().join("corrupt.wiff.scan"), b"scan").unwrap(); + + let error = load(&path).unwrap_err().to_string(); + + assert!( + error.starts_with("invalid or unsupported SCIEX WIFF:"), + "{error}" + ); +} + +#[test] +fn rejects_an_empty_sample_container() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("samples.wiff"); + let file = std::fs::File::create(&path).unwrap(); + let mut compound = cfb::CompoundFile::create(file).unwrap(); + compound.create_storage("SampleSubtree").unwrap(); + compound.flush().unwrap(); + drop(compound); + std::fs::write(directory.path().join("samples.wiff.scan"), b"scan").unwrap(); + + let error = load(&path).unwrap_err().to_string(); + + assert!(error.contains("contains no samples"), "{error}"); +} + +#[test] +fn loads_a_synthetic_single_sample_wiff_pair_end_to_end() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("single.wiff"); + let scan_path = write_synthetic_pair(&path, &[("Sample1", 1, 1.25, 14.0)]); + + let loaded = crate::load_path(&path).unwrap(); + + assert_eq!( + loaded.format, + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) + ); + assert_eq!(loaded.provenance.companion_paths, vec![scan_path]); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("WIFF should produce a mass-spectrometry run"); + }; + assert_eq!(run.streams.len(), 1); + assert_eq!( + run.streams[0].source_label.as_deref(), + Some("Sample1 - MS1 unknown") + ); + assert_eq!( + run.metadata.get("sample count").map(String::as_str), + Some("1") + ); + let spectrum = &run.streams[0].spectra[0]; + assert_eq!( + spectrum.source_native_id.as_deref(), + Some("file=single scan=1") + ); + assert_eq!(spectrum.retention_time_min, 1.25); + assert_eq!(spectrum.mz, vec![100.0, 110.0]); + assert_eq!(spectrum.intensity, vec![5.0, 9.0]); + assert_eq!(spectrum.tic, 14.0); + assert_eq!(run.chromatograms.len(), 1); + assert_eq!(run.chromatograms[0].time_min, vec![1.25]); + assert_eq!(run.chromatograms[0].values, vec![14.0]); +} + +#[test] +fn loads_all_samples_as_distinct_streams_and_chromatograms() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("multi.wiff"); + let scan_path = write_synthetic_pair( + &path, + &[("Sample1", 1, 1.25, 14.0), ("Sample2", 2, 2.5, 28.0)], + ); + + let loaded = crate::load_path(&path).unwrap(); + + assert_eq!(loaded.provenance.companion_paths, vec![scan_path]); + assert_eq!(loaded.acquisition_identity.subject, None); + assert_eq!( + loaded.acquisition_identity.acquisition.as_deref(), + Some("2 samples") + ); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("WIFF should produce a mass-spectrometry run"); + }; + assert_eq!( + run.metadata.get("sample count").map(String::as_str), + Some("2") + ); + assert_eq!( + run.metadata.get("samples").map(String::as_str), + Some("Sample1, Sample2") + ); + assert_eq!(run.streams.len(), 2); + assert_eq!(run.streams[0].id, AcquisitionStreamId::new(1)); + assert_eq!(run.streams[1].id, AcquisitionStreamId::new(2)); + assert_eq!( + run.streams[0].source_label.as_deref(), + Some("Sample1 - MS1 unknown") + ); + assert_eq!( + run.streams[1].source_label.as_deref(), + Some("Sample2 - MS2 unknown") + ); + assert_eq!(run.streams[0].spectra[0].retention_time_min, 1.25); + assert_eq!(run.streams[1].spectra[0].retention_time_min, 2.5); + assert_eq!( + run.chromatograms + .iter() + .map(|channel| channel.id.0.as_str()) + .collect::>(), + vec!["Sample1:TIC", "Sample2:TIC"] + ); + assert_eq!(run.chromatograms[0].values, vec![14.0]); + assert_eq!(run.chromatograms[1].values, vec![28.0]); +} + +#[test] +fn maps_spectrum_identity_time_polarity_precursor_and_summaries() { + let streams = + build_streams(vec![source_spectrum()], "Sample1", &mut 1, &mut Vec::new()).unwrap(); + let stream = &streams[0]; + let spectrum = &stream.spectra[0]; + + assert_eq!(stream.acquisition_range, Some([100.0, 600.0])); + assert_eq!( + stream.source_label.as_deref(), + Some("Sample1 - MS2 positive") + ); + assert_eq!(spectrum.id, SpectrumId::new(5)); + assert_eq!( + spectrum.source_native_id.as_deref(), + Some("file=fixture scan=5") + ); + assert_eq!(spectrum.retention_time_min, 1.5); + assert_eq!(spectrum.ms_level, 2); + assert_eq!(spectrum.polarity, Polarity::Positive); + assert_eq!(spectrum.representation, SpectrumRepresentation::Centroid); + assert_eq!(spectrum.mz.len(), spectrum.intensity.len()); + assert_eq!(spectrum.tic, 21.0); + assert_eq!(spectrum.base_peak_mz, Some(250.0)); + assert_eq!(spectrum.base_peak_intensity, Some(11.0)); + let precursor = spectrum.precursor.as_ref().unwrap(); + assert_eq!(precursor.selected_mz, 445.34); + assert_eq!(precursor.charge, Some(2)); + assert_eq!(precursor.isolation_window_lower_offset, Some(1.0)); + assert_eq!(precursor.isolation_window_upper_offset, Some(1.0)); + assert_eq!(precursor.collision_energy, Some(30.0)); + assert_eq!(precursor.activation_method.as_deref(), Some("CID")); +} + +#[test] +fn rejects_a_sample_with_no_decoded_spectra() { + let mut record = source_spectrum(); + record.mz.clear(); + record.intensity.clear(); + + let mut warnings = Vec::new(); + let error = build_streams(vec![record], "Sample1", &mut 1, &mut warnings) + .unwrap_err() + .to_string(); + + assert!(error.contains("Sample1"), "{error}"); + assert!(error.contains("contains no decoded spectra"), "{error}"); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("file=fixture scan=5")); + assert!(warnings[0].contains("was skipped")); +} + +#[test] +fn skips_an_empty_scan_when_the_sample_has_readable_spectra() { + let mut empty = source_spectrum(); + empty.native_id = "file=fixture scan=4".to_owned(); + empty.scan_number = 4; + empty.mz.clear(); + empty.intensity.clear(); + let mut warnings = Vec::new(); + + let streams = build_streams( + vec![empty, source_spectrum()], + "Sample1", + &mut 1, + &mut warnings, + ) + .unwrap(); + + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].spectra.len(), 1); + assert_eq!(streams[0].spectra[0].id, SpectrumId::new(5)); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("file=fixture scan=4")); +} + +#[test] +fn local_wiff_fixture_imports_validated_multi_sample_layout_when_present() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(".tmp/WIFF/20250305.wiff"); + if !path.is_file() { + return; + } + + let loaded = load(&path).expect("local legacy WIFF fixture should import every sample"); + assert!( + loaded.warnings.is_empty(), + "valid empty scan headers are not import warnings" + ); + assert_eq!( + loaded.format, + DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) + ); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("WIFF should produce a mass-spectrometry run"); + }; + assert_eq!(run.metadata["sample count"].parse::().unwrap(), 2); + assert_eq!(run.metadata["samples"], "yjs_10ppm #1, yjs_10ppm #2"); + assert_eq!(run.streams.len(), 22); + assert_eq!(run.chromatograms.len(), 22); + let sample0: usize = run.streams[..11] + .iter() + .flat_map(|stream| &stream.spectra) + .filter(|spectrum| spectrum.tic > 0.0) + .count(); + let sample1: usize = run.streams[11..] + .iter() + .flat_map(|stream| &stream.spectra) + .filter(|spectrum| spectrum.tic > 0.0) + .count(); + assert_eq!((sample0, sample1), (3141, 3072)); + assert!( + run.streams + .iter() + .flat_map(|stream| &stream.spectra) + .all(|spectrum| { + spectrum.mz.len() == spectrum.intensity.len() + && spectrum.retention_time_min.is_finite() + && spectrum.mz.iter().all(|value| value.is_finite()) + }) + ); + let tic = &run.chromatograms[0]; + assert_eq!(tic.time_min.len(), 3905); + assert!(tic.time_min.windows(2).all(|pair| pair[1] > pair[0])); + assert!((tic.time_min[0] - 0.002533333333333333).abs() < 1e-9); + assert!((tic.time_min[3904] - 13.49435).abs() < 1e-9); + let ms1 = &run.streams[0]; + let peak = ms1 + .spectra + .iter() + .max_by(|left, right| left.tic.total_cmp(&right.tic)) + .unwrap(); + assert!((peak.retention_time_min - 0.9720333333333334).abs() < 1e-9); + assert_eq!(peak.tic, 5374726.0); + assert_eq!(loaded.provenance.companion_paths.len(), 1); +} diff --git a/crates/io/src/sciex_wiff_tic.rs b/crates/io/src/sciex_wiff_tic.rs new file mode 100644 index 0000000..1a62793 --- /dev/null +++ b/crates/io/src/sciex_wiff_tic.rs @@ -0,0 +1,62 @@ +use crate::{ + AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, IoError, +}; + +pub(super) fn channels( + idx: &[(usize, f64, f32, f64)], + streams: &[AcquisitionStream], + sample: &str, + multiple: bool, +) -> Result, IoError> { + let count = idx.iter().map(|r| r.0).max().map_or(0, |v| v + 1); + (0..count) + .map(|experiment| { + let source = streams.iter().find(|stream| { + stream + .source_native_id + .as_deref() + .is_some_and(|id| id.contains(&format!("experiment={}", experiment + 1))) + }); + let (time_min, values): (Vec<_>, Vec<_>) = idx + .iter() + .filter(|r| r.0 == experiment) + .map(|r| { + ( + if idx.len() == 1 { + f64::from(r.2) + } else { + r.1 / 60_000.0 + }, + r.3, + ) + }) + .unzip(); + if time_min.is_empty() { + return Err(IoError::InvalidSciexWiff(format!( + "WIFF sample {sample} has no TIC records for experiment {}", + experiment + 1 + ))); + } + let prefix = if multiple { + format!("{sample}:") + } else { + String::new() + }; + let local = if count == 1 { + "TIC".to_owned() + } else { + format!("Experiment{}:TIC", experiment + 1) + }; + Ok(ChromatogramChannel { + id: ChromatogramChannelId(format!("{prefix}{local}")), + kind: ChromatogramKind::Unknown, + source_stream: source.map(|s| s.id), + coordinate: Some((experiment + 1) as f64), + description: format!("{sample} experiment {} total ion current", experiment + 1), + unit: "cps".to_owned(), + time_min, + values, + }) + }) + .collect() +} diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index 9e2c5b8..b607371 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -14,6 +14,7 @@ no conversion step is needed. | Bruker TopSpin | `fid` / `ser` directories | 1D and 2D | | Varian/Agilent VnmrJ | `.fid` directory | Raw time-domain 1D and conventional 2D | | Waters MassLynx RAW | `.raw` directory | Validated low-resolution runs, including SQD2 data | +| SCIEX legacy WIFF | `.wiff` + `.wiff.scan` | Single- and multi-sample legacy runs; both files must remain together | | Rigaku powder XRD | `.rasx`, FI `.raw`, RAS_RAW `.txt` | Diffraction pattern, acquisition metadata, and attenuation when available | | mzML | `.mzML` | Centroided or profile LC–MS spectra with 32-bit or 64-bit arrays, uncompressed or zlib-compressed | | Bruker NanoScope AFM | `.spm` / `.pfc` | Images, force curves, force-volume and PeakForce Capture cubes | @@ -35,7 +36,9 @@ TopSpin, Varian/Agilent VnmrJ, and Waters MassLynx RAW), *Open Project…*, or Each imported dataset appears in the Primary Side Bar and is placed on the board automatically. The file picker accepts several ABF files at once. Opening a folder recursively -imports every `.abf`, `.spm`, `.pfc`, `.vms`, structured CasaXPS `.txt`, and recognized `.raw` bundle below it. +imports every `.abf`, `.spm`, `.pfc`, `.vms`, `.wiff`, structured CasaXPS +`.txt`, and recognized `.raw` bundle below it. A `.wiff.scan` companion is +never imported as a separate dataset. A `.raw` directory is imported once as a complete run; its internal files are not treated as separate datasets. For ABF files, each immediate parent folder becomes the initial, editable cell ID. @@ -65,6 +68,22 @@ The importer accepts little-endian 32-bit and 64-bit floating-point m/z and intensity arrays with no compression or zlib compression. Numpress, big-endian arrays, and spectra without both required arrays stop the import with an error. +## SCIEX legacy WIFF + +Open or drop the `.wiff` file. Keep the paired file with `.scan` appended to +the full filename beside it, for example `sample.wiff` and +`sample.wiff.scan`. PlotX imports native scan IDs, retention times, m/z and +intensity arrays, precursor details when available, polarity, instrument and +acquisition-start metadata, and a separate TIC for each verified experiment. +Spectra are grouped into independent sample/experiment acquisition streams and +retain cycle order, including zero-TIC and empty DDA slots. Duplicate sample +names remain separate and are displayed with stable suffixes such as +`yjs_10ppm #1` and `yjs_10ppm #2`. + +PlotX rejects a missing companion, an empty container, or an unrecognized WIFF +layout rather than creating a partial dataset. SCIEX `.wiff2` and `.timeseries.data` are not supported; +convert those acquisitions to mzML before opening them in PlotX. + ## Rigaku powder XRD Open the `.rasx` file when it is available. PlotX reads the measured 2theta, diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index 340c7f3..81142a2 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -73,6 +73,23 @@ non-uniform sampling, and arrayed parameters other than phase are not supported. The import also stops if the recorded dimensions do not match the data. +## SCIEX legacy WIFF + +PlotX reads a legacy `.wiff` OLE metadata container together with the +`.wiff.scan` payload whose name is formed by appending `.scan` to the complete +`.wiff` filename. The importer is pure Rust and does not require SCIEX Analyst, +SCIEX OS, or a ProteoWizard SDK installation. + +Only the validated legacy layout is supported. The native importer reads the +sample subtree/index streams, preserves duplicate sample names as independent +samples, and exposes each of the 11 verified experiment slots as its own +acquisition stream and TIC channel. Records remain in cycle order, including +zero-TIC and empty DDA slots; no cross-experiment time merging or interpolation +is performed. Unknown or structurally different WIFF variants are rejected +before a dataset is created. Encrypted or newer `.wiff2` and +`.timeseries.data` input is outside this boundary and must first be converted +to mzML. + ## Workflow and run-record files An [automation](/guides/automation/) workflow file is a JSON description of a diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 9e95101..2315761 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -13,6 +13,7 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 | Bruker TopSpin | `fid` / `ser` 目录 | 1D 与 2D | | Varian/Agilent VnmrJ | `.fid` 目录 | 原始时域 1D 与常规 2D | | Waters MassLynx RAW | `.raw` 目录 | 已验证的低分辨率数据,包括 SQD2 数据 | +| SCIEX legacy WIFF | `.wiff` + `.wiff.scan` | 支持单样本与多样本 legacy 数据;两个文件必须放在一起 | | Rigaku 粉末 XRD | `.rasx`、FI `.raw`、RAS_RAW `.txt` | 衍射图样、采集元数据,以及文件提供的衰减系数 | | mzML | `.mzML` | 使用 32 位或 64 位、未压缩或 zlib 压缩数组的质心或轮廓 LC–MS 谱图 | | Bruker NanoScope AFM | `.spm` / `.pfc` | 图像、力曲线、Force Volume 与 PeakForce Capture 数据立方体 | @@ -32,7 +33,8 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 *Open Project…* 或 *Import Table…*。每个导入的数据集会出现在主侧栏中, 并自动放置到画板上。 文件选择器可以一次选择多个 ABF。打开文件夹时会递归导入其中所有 `.abf`、 -`.spm`、`.pfc`、`.vms`、结构化 CasaXPS `.txt` 和已识别的 `.raw` 数据包。每个 `.raw` 目录会作为一次完整采集 +`.spm`、`.pfc`、`.vms`、`.wiff`、结构化 CasaXPS `.txt` 和已识别的 `.raw` +数据包;配套的 `.wiff.scan` 不会作为独立数据集导入。每个 `.raw` 目录会作为一次完整采集 导入一次,其中的内部文件不会被当作独立数据集。对 ABF 文件,每个文件的直接 父目录名会成为可编辑的初始 cell ID。 CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.txt` 仍进入表格导入。 @@ -56,6 +58,19 @@ CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.tx 导入器支持小端 32 位和 64 位浮点 m/z 与强度数组,可不压缩或使用 zlib 压缩。 Numpress、大端数组以及缺少任一必需数组的谱图会使导入停止并显示错误。 +## SCIEX legacy WIFF + +请打开或拖入 `.wiff` 文件,并将文件名末尾追加 `.scan` 的配套文件放在同一 +目录,例如 `sample.wiff` 与 `sample.wiff.scan`。PlotX 会导入原始 scan ID、 +保留时间、m/z 与强度数组、可用的 precursor 信息、极性、仪器与采集开始时间 +元数据,以及每个已验证 experiment 的独立 TIC。谱图按样本与 experiment 分成独立 +的 acquisition stream,并保留 cycle 顺序,包括零 TIC 和空 DDA 槽位。重复样本名 +不会合并,会稳定显示为 `yjs_10ppm #1`、`yjs_10ppm #2` 等后缀。 + +缺少配套文件、容器无样本或 WIFF 布局未识别时,PlotX 会明确拒绝,不会创建不完整的数据集。 +暂不支持 SCIEX `.wiff2` 与 `.timeseries.data`;请先将这些采集转换为 mzML, +再在 PlotX 中打开。 + ## Rigaku 粉末 XRD 有 `.rasx` 时请优先打开该文件。PlotX 会同时读取实测 2theta、强度、衰减系数, diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index 4da449a..0d6c39a 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -59,6 +59,19 @@ States 2D 数据。暂不支持处理后的谱图、3D 或 4D 实验、成像、 非均匀采样,以及除 phase 以外的参数数组。如果文件记录的维度与数据不一致, 导入也会停止。 +## SCIEX legacy WIFF + +PlotX 会同时读取 legacy `.wiff` OLE 元数据容器,以及在完整 `.wiff` 文件名 +末尾追加 `.scan` 所得到的 `.wiff.scan` payload。导入器使用纯 Rust,不要求 +安装 SCIEX Analyst、SCIEX OS 或 ProteoWizard SDK。 + +目前仅支持已验证的 legacy 布局。原生导入器会读取 sample subtree/index 流,保留 +重复样本为独立样本,并将已验证的 11 个 experiment 槽位分别提供为独立的 +acquisition stream 与 TIC channel。记录按 cycle 顺序保留,包括零 TIC 和空 DDA +槽位;不会跨 experiment 合并时间或插值。未知或结构不同的 WIFF 变体会在创建 +数据集之前被拒绝。加密或较新的 `.wiff2` 与 +`.timeseries.data` 不在此边界内,必须先转换为 mzML。 + ## 工作流与运行记录文件 [自动化](/zh-cn/guides/automation/)工作流文件是一次批处理运行的 JSON