diff --git a/crates/analysis/src/mass_spec.rs b/crates/analysis/src/mass_spec.rs index aa95d08..fab8f69 100644 --- a/crates/analysis/src/mass_spec.rs +++ b/crates/analysis/src/mass_spec.rs @@ -115,7 +115,10 @@ pub fn extract_spectrum( #[cfg(test)] mod tests { use super::*; - use plotx_io::{MassSpectrum, Polarity, SpectrumId, SpectrumRepresentation}; + use plotx_io::{ + MassSpectrum, Polarity, SpectrumAcquisition, SpectrumId, SpectrumRepresentation, + SpectrumSummaryProvenance, + }; fn scan(id: u64, time: f64, mz: &[f64], intensity: &[f64]) -> MassSpectrum { MassSpectrum { @@ -125,11 +128,14 @@ mod tests { ms_level: 1, polarity: Polarity::Positive, representation: SpectrumRepresentation::Profile, + acquisition: SpectrumAcquisition::default(), mz: mz.to_vec(), intensity: intensity.to_vec(), tic: 0.0, + tic_provenance: SpectrumSummaryProvenance::Derived, base_peak_mz: None, base_peak_intensity: None, + base_peak_provenance: SpectrumSummaryProvenance::Derived, precursor: None, } } diff --git a/crates/app/src/ui/batch_workflow_tests.rs b/crates/app/src/ui/batch_workflow_tests.rs index 6209e8d..688df48 100644 --- a/crates/app/src/ui/batch_workflow_tests.rs +++ b/crates/app/src/ui/batch_workflow_tests.rs @@ -26,11 +26,14 @@ fn selecting_a_canvas_resolves_its_mass_spec_dataset() { ms_level: 1, polarity: Polarity::Unknown, representation: SpectrumRepresentation::Centroid, + acquisition: plotx_io::SpectrumAcquisition::default(), mz: vec![100.0], intensity: vec![1.0], tic: 1.0, + tic_provenance: plotx_io::SpectrumSummaryProvenance::Derived, base_peak_mz: Some(100.0), base_peak_intensity: Some(1.0), + base_peak_provenance: plotx_io::SpectrumSummaryProvenance::Derived, precursor: None, }], }], diff --git a/crates/app/src/ui/commands_mass_spec_tests.rs b/crates/app/src/ui/commands_mass_spec_tests.rs index 458af22..dd87f43 100644 --- a/crates/app/src/ui/commands_mass_spec_tests.rs +++ b/crates/app/src/ui/commands_mass_spec_tests.rs @@ -25,11 +25,14 @@ fn app_with_mass_spec() -> PlotxApp { ms_level: 1, polarity: Polarity::Positive, representation: SpectrumRepresentation::Profile, + acquisition: plotx_io::SpectrumAcquisition::default(), mz: vec![20.0, 40.0], intensity: vec![2.0, 5.0], tic: 7.0, + tic_provenance: plotx_io::SpectrumSummaryProvenance::Derived, base_peak_mz: Some(40.0), base_peak_intensity: Some(5.0), + base_peak_provenance: plotx_io::SpectrumSummaryProvenance::Derived, precursor: None, }], }], diff --git a/crates/app/src/ui/scientific_script.rs b/crates/app/src/ui/scientific_script.rs index d4818de..4bbb022 100644 --- a/crates/app/src/ui/scientific_script.rs +++ b/crates/app/src/ui/scientific_script.rs @@ -137,8 +137,31 @@ pub(crate) fn prepare_run( stream.spectra.iter().map(move |scan| { serde_json::json!({ "stream_id": stream.id.get(), + "spectrum_id": scan.id.get(), + "source_native_id": scan.source_native_id, "time_min": scan.retention_time_min, + "ms_level": scan.ms_level, "tic": scan.tic, + "tic_provenance": summary_provenance_label(scan.tic_provenance), + "base_peak_mz": scan.base_peak_mz, + "base_peak_intensity": scan.base_peak_intensity, + "base_peak_provenance": summary_provenance_label(scan.base_peak_provenance), + "acquisition": { + "instrument_configuration_id": scan.acquisition.instrument_configuration_id, + "source_event_id": scan.acquisition.source_event_id, + "filter_string": scan.acquisition.filter_string, + }, + "precursor": scan.precursor.as_ref().map(|precursor| serde_json::json!({ + "source_spectrum_native_id": precursor.source_spectrum_native_id, + "selected_mz": precursor.selected_mz, + "selected_intensity": precursor.selected_intensity, + "charge": precursor.charge, + "isolation_window_target_mz": precursor.isolation_window_target_mz, + "isolation_window_lower_offset": precursor.isolation_window_lower_offset, + "isolation_window_upper_offset": precursor.isolation_window_upper_offset, + "collision_energy": precursor.collision_energy, + "activation_method": precursor.activation_method, + })), }) }) }) @@ -152,6 +175,13 @@ pub(crate) fn prepare_run( }) } +fn summary_provenance_label(provenance: plotx_io::SpectrumSummaryProvenance) -> &'static str { + match provenance { + plotx_io::SpectrumSummaryProvenance::Source => "source", + plotx_io::SpectrumSummaryProvenance::Derived => "derived", + } +} + fn floats(values: Array, name: &str) -> Result, Box> { values .into_iter() @@ -251,6 +281,68 @@ fn rolling_percentile(values: &[f64], width: usize, quantile: f64) -> Vec { mod tests { use super::*; + #[test] + fn prepared_mass_spec_scans_expose_precursor_metadata() { + let run = MassSpecRun { + source: "fixture.mzML".to_owned(), + metadata: std::collections::BTreeMap::new(), + instrument: None, + streams: vec![plotx_io::AcquisitionStream { + id: plotx_io::AcquisitionStreamId::new(1), + source_native_id: None, + source_label: None, + role: plotx_io::StreamRole::Primary, + acquisition_range: None, + spectra: vec![plotx_io::MassSpectrum { + id: plotx_io::SpectrumId::new(2), + source_native_id: Some("scan=2".to_owned()), + retention_time_min: 1.5, + ms_level: 2, + polarity: plotx_io::Polarity::Positive, + representation: plotx_io::SpectrumRepresentation::Centroid, + acquisition: plotx_io::SpectrumAcquisition { + instrument_configuration_id: Some("IC2".to_owned()), + source_event_id: Some(3), + filter_string: Some("ITMS MS2".to_owned()), + }, + mz: vec![100.0], + intensity: vec![5.0], + tic: 5.0, + tic_provenance: plotx_io::SpectrumSummaryProvenance::Source, + base_peak_mz: Some(100.0), + base_peak_intensity: Some(5.0), + base_peak_provenance: plotx_io::SpectrumSummaryProvenance::Source, + precursor: Some(plotx_io::Precursor { + source_spectrum_native_id: Some("scan=1".to_owned()), + selected_mz: Some(445.2), + selected_intensity: Some(1_200.0), + charge: Some(2), + isolation_window_target_mz: Some(445.0), + isolation_window_lower_offset: Some(0.5), + isolation_window_upper_offset: Some(0.5), + collision_energy: Some(25.0), + activation_method: Some("CID".to_owned()), + }), + }], + }], + chromatograms: Vec::new(), + import_warnings: Vec::new(), + }; + + let prepared = prepare_run(&run, None); + let scan = &prepared["scans"][0]; + assert_eq!(scan["spectrum_id"], 2); + assert_eq!(scan["tic_provenance"], "source"); + assert_eq!(scan["base_peak_provenance"], "source"); + assert_eq!(scan["acquisition"]["instrument_configuration_id"], "IC2"); + assert_eq!(scan["acquisition"]["source_event_id"], 3); + assert_eq!(scan["acquisition"]["filter_string"], "ITMS MS2"); + assert_eq!(scan["precursor"]["source_spectrum_native_id"], "scan=1"); + assert_eq!(scan["precursor"]["selected_mz"], 445.2); + assert_eq!(scan["precursor"]["isolation_window_target_mz"], 445.0); + assert_eq!(scan["precursor"]["activation_method"], "CID"); + } + #[test] fn script_cannot_read_an_unselected_path() { let error = run("load_input()", Path::new("missing.raw")).unwrap_err(); diff --git a/crates/core/src/project/mass_spec_convert.rs b/crates/core/src/project/mass_spec_convert.rs index b22bb36..4f6a41b 100644 --- a/crates/core/src/project/mass_spec_convert.rs +++ b/crates/core/src/project/mass_spec_convert.rs @@ -5,8 +5,8 @@ use crate::state::{ }; use plotx_io::{ AcquisitionStream, AcquisitionStreamId, ChromatogramChannel, ChromatogramChannelId, - ChromatogramKind, MassSpecRun, MassSpectrum, Polarity, Precursor, SpectrumId, - SpectrumRepresentation, StreamRole, + ChromatogramKind, MassSpecRun, MassSpectrum, Polarity, Precursor, SpectrumAcquisition, + SpectrumId, SpectrumRepresentation, SpectrumSummaryProvenance, StreamRole, }; use std::collections::BTreeMap; use std::io::{Read, Write}; @@ -133,21 +133,45 @@ fn write_spectrum(output: &mut impl Write, spectrum: &MassSpectrum) -> Result<() SpectrumRepresentation::Unknown => 2, }, )?; + write_optional_string( + output, + spectrum.acquisition.instrument_configuration_id.as_deref(), + )?; + write_optional_u64(output, spectrum.acquisition.source_event_id.map(u64::from))?; + write_optional_string(output, spectrum.acquisition.filter_string.as_deref())?; write_f64(output, spectrum.tic)?; + write_summary_provenance(output, spectrum.tic_provenance)?; write_optional_f64(output, spectrum.base_peak_mz)?; write_optional_f64(output, spectrum.base_peak_intensity)?; + write_summary_provenance(output, spectrum.base_peak_provenance)?; write_optional_precursor(output, spectrum.precursor.as_ref())?; write_f64s(output, &spectrum.mz)?; write_f64s(output, &spectrum.intensity) } +fn write_summary_provenance( + output: &mut impl Write, + provenance: SpectrumSummaryProvenance, +) -> Result<()> { + write_u8( + output, + match provenance { + SpectrumSummaryProvenance::Source => 0, + SpectrumSummaryProvenance::Derived => 1, + }, + ) +} + fn write_optional_precursor(output: &mut impl Write, precursor: Option<&Precursor>) -> Result<()> { let Some(precursor) = precursor else { return write_u8(output, 0); }; write_u8(output, 1)?; - write_f64(output, precursor.selected_mz)?; + write_optional_string(output, precursor.source_spectrum_native_id.as_deref())?; + write_optional_f64(output, precursor.selected_mz)?; + write_optional_f64(output, precursor.selected_intensity)?; write_optional_i32(output, precursor.charge)?; + write_optional_f64(output, precursor.isolation_window_target_mz)?; write_optional_f64(output, precursor.isolation_window_lower_offset)?; write_optional_f64(output, precursor.isolation_window_upper_offset)?; write_optional_f64(output, precursor.collision_energy)?; @@ -533,9 +557,25 @@ impl<'a, 'p, R: Read> Reader<'a, 'p, R> { 2 => SpectrumRepresentation::Unknown, tag => return Err(invalid_tag("spectrum representation", tag)), }; + let acquisition = SpectrumAcquisition { + instrument_configuration_id: self.read_optional_string()?, + source_event_id: self + .read_optional_u64()? + .map(|value| { + u32::try_from(value).map_err(|_| { + ProjectError::Invalid( + "LC–MS payload source event ID exceeds u32".to_owned(), + ) + }) + }) + .transpose()?, + filter_string: self.read_optional_string()?, + }; let tic = self.read_f64()?; + let tic_provenance = self.read_summary_provenance()?; let base_peak_mz = self.read_optional_f64()?; let base_peak_intensity = self.read_optional_f64()?; + let base_peak_provenance = self.read_summary_provenance()?; let precursor = self.read_precursor()?; let mz = self.read_f64s()?; let intensity = self.read_f64s()?; @@ -546,22 +586,36 @@ impl<'a, 'p, R: Read> Reader<'a, 'p, R> { ms_level, polarity, representation, + acquisition, mz, intensity, tic, + tic_provenance, base_peak_mz, base_peak_intensity, + base_peak_provenance, precursor, }) } + fn read_summary_provenance(&mut self) -> Result { + match self.read_u8()? { + 0 => Ok(SpectrumSummaryProvenance::Source), + 1 => Ok(SpectrumSummaryProvenance::Derived), + tag => Err(invalid_tag("spectrum summary provenance", tag)), + } + } + fn read_precursor(&mut self) -> Result> { if !self.read_option_tag()? { return Ok(None); } Ok(Some(Precursor { - selected_mz: self.read_f64()?, + source_spectrum_native_id: self.read_optional_string()?, + selected_mz: self.read_optional_f64()?, + selected_intensity: self.read_optional_f64()?, charge: self.read_optional_i32()?, + isolation_window_target_mz: self.read_optional_f64()?, isolation_window_lower_offset: self.read_optional_f64()?, isolation_window_upper_offset: self.read_optional_f64()?, collision_energy: self.read_optional_f64()?, diff --git a/crates/core/src/project/mass_spec_convert_tests.rs b/crates/core/src/project/mass_spec_convert_tests.rs index 0694a39..07c60e5 100644 --- a/crates/core/src/project/mass_spec_convert_tests.rs +++ b/crates/core/src/project/mass_spec_convert_tests.rs @@ -80,9 +80,19 @@ fn rejects_large_structural_counts_without_reserving_the_claimed_collection() { fn payload_round_trips_spectra_channels_precursors_and_transitions() { let mut run = crate::state::sample_mass_spec_run(); run.instrument = Some("QTOF".to_owned()); + run.streams[0].spectra[1].acquisition = SpectrumAcquisition { + instrument_configuration_id: Some("IC2".to_owned()), + source_event_id: Some(7), + filter_string: Some("ITMS MS2".to_owned()), + }; + run.streams[0].spectra[1].tic_provenance = SpectrumSummaryProvenance::Source; + run.streams[0].spectra[1].base_peak_provenance = SpectrumSummaryProvenance::Source; run.streams[0].spectra[1].precursor = Some(Precursor { - selected_mz: 445.2, + source_spectrum_native_id: Some("scan=10".to_owned()), + selected_mz: Some(445.2), + selected_intensity: Some(1_200.0), charge: Some(2), + isolation_window_target_mz: Some(445.0), isolation_window_lower_offset: Some(0.5), isolation_window_upper_offset: Some(0.75), collision_energy: Some(20.0), @@ -105,9 +115,34 @@ fn payload_round_trips_spectra_channels_precursors_and_transitions() { assert_eq!(decoded.streams[0].role, StreamRole::Primary); assert_eq!(decoded.streams[0].spectra[1].id, SpectrumId::new(12)); assert_eq!(decoded.streams[0].spectra[1].mz, [20.0, 30.0]); + assert_eq!( + decoded.streams[0].spectra[1] + .acquisition + .instrument_configuration_id + .as_deref(), + Some("IC2") + ); + assert_eq!( + decoded.streams[0].spectra[1].acquisition.source_event_id, + Some(7) + ); + assert_eq!( + decoded.streams[0].spectra[1].tic_provenance, + SpectrumSummaryProvenance::Source + ); + assert_eq!( + decoded.streams[0].spectra[1].base_peak_provenance, + SpectrumSummaryProvenance::Source + ); let precursor = decoded.streams[0].spectra[1].precursor.as_ref().unwrap(); - assert_eq!(precursor.selected_mz, 445.2); + assert_eq!( + precursor.source_spectrum_native_id.as_deref(), + Some("scan=10") + ); + assert_eq!(precursor.selected_mz, Some(445.2)); + assert_eq!(precursor.selected_intensity, Some(1_200.0)); assert_eq!(precursor.charge, Some(2)); + assert_eq!(precursor.isolation_window_target_mz, Some(445.0)); assert_eq!(precursor.activation_method.as_deref(), Some("CID")); let channel = &decoded.chromatograms[0]; assert_eq!(channel.kind, ChromatogramKind::SelectedReactionMonitoring); diff --git a/crates/core/src/state/mass_spec_fixture.rs b/crates/core/src/state/mass_spec_fixture.rs index 91cc948..92bf0f3 100644 --- a/crates/core/src/state/mass_spec_fixture.rs +++ b/crates/core/src/state/mass_spec_fixture.rs @@ -1,6 +1,7 @@ use super::*; use plotx_io::{ - AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, Polarity, SpectrumRepresentation, + AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, Polarity, SpectrumAcquisition, + SpectrumRepresentation, SpectrumSummaryProvenance, }; pub(crate) fn sample_mass_spec_run() -> MassSpecRun { @@ -11,11 +12,14 @@ pub(crate) fn sample_mass_spec_run() -> MassSpecRun { ms_level: 1, polarity, representation: SpectrumRepresentation::Profile, + acquisition: SpectrumAcquisition::default(), mz: mz.to_vec(), intensity: intensity.to_vec(), tic, + tic_provenance: SpectrumSummaryProvenance::Derived, base_peak_mz: mz.first().copied(), base_peak_intensity: intensity.first().copied(), + base_peak_provenance: SpectrumSummaryProvenance::Derived, precursor: None, }; MassSpecRun { diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index d3eb259..fd2c2b2 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -4,6 +4,7 @@ use crate::state::{ AxisRange, Dataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesSource, ToolGroup, }; use plotx_io::{ChromatogramChannel, ChromatogramChannelId}; +use std::path::Path; #[test] fn dynamic_catalog_and_stable_selection_follow_stream_identity() { @@ -135,6 +136,57 @@ fn stream_tic_prefers_bound_chromatogram_points() { assert_eq!(points, [[0.0, 11.0], [2.0, 22.0]]); } +#[test] +fn stream_tic_without_a_bound_channel_uses_spectrum_summaries() { + let dataset = MassSpecDataset::load(sample_mass_spec_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.5, 2.0], [1.0, 9.0]]); + assert_eq!( + dataset.run.streams[0].spectra[1] + .intensity + .iter() + .sum::(), + 10.0 + ); +} + +#[test] +fn local_small_mzml_figure_matches_the_14_point_ms1_tic_when_present() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(".tmp/MS-data/hupo-psi-mzpeak-small/small.mzML"); + if !path.is_file() { + return; + } + let loaded = plotx_io::mzml::load(&path).unwrap(); + let plotx_io::Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("small.mzML did not import as mass spectrometry data"); + }; + let dataset = MassSpecDataset::load(*run); + let field = dataset + .field_catalog + .id_for_key(&stream_tic_key(dataset.active_stream)) + .unwrap(); + let figure = dataset.field_figure(field).unwrap(); + + assert_eq!(figure.series[0].points.len(), 14); + assert_eq!( + figure.series[0] + .points + .iter() + .copied() + .max_by(|left, right| left[1].total_cmp(&right[1])), + Some([0.285483333333, 22_136_832.0]) + ); + assert_eq!(figure.series[0].points[1], [0.007896666667, 12_901_166.0]); +} + #[test] fn displayed_mass_spec_trace_uses_bound_tic_channel() { let mut run = sample_mass_spec_run(); diff --git a/crates/io/src/mass_spec.rs b/crates/io/src/mass_spec.rs index 57c5f98..ed11da8 100644 --- a/crates/io/src/mass_spec.rs +++ b/crates/io/src/mass_spec.rs @@ -64,10 +64,27 @@ pub enum SpectrumRepresentation { Unknown, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpectrumSummaryProvenance { + Source, + Derived, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpectrumAcquisition { + pub instrument_configuration_id: Option, + pub source_event_id: Option, + pub filter_string: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Precursor { - pub selected_mz: f64, + pub source_spectrum_native_id: Option, + pub selected_mz: Option, + pub selected_intensity: Option, pub charge: Option, + pub isolation_window_target_mz: Option, pub isolation_window_lower_offset: Option, pub isolation_window_upper_offset: Option, pub collision_energy: Option, @@ -82,11 +99,14 @@ pub struct MassSpectrum { pub ms_level: u8, pub polarity: Polarity, pub representation: SpectrumRepresentation, + pub acquisition: SpectrumAcquisition, pub mz: Vec, pub intensity: Vec, pub tic: f64, + pub tic_provenance: SpectrumSummaryProvenance, pub base_peak_mz: Option, pub base_peak_intensity: Option, + pub base_peak_provenance: SpectrumSummaryProvenance, pub precursor: Option, } @@ -342,6 +362,16 @@ impl MassSpecRun { fn validate_spectrum(stream: AcquisitionStreamId, spectrum: &MassSpectrum) -> Result<(), String> { if spectrum.ms_level == 0 || !spectrum.retention_time_min.is_finite() + || spectrum + .acquisition + .instrument_configuration_id + .as_deref() + .is_some_and(str::is_empty) + || spectrum + .acquisition + .filter_string + .as_deref() + .is_some_and(str::is_empty) || spectrum.mz.len() != spectrum.intensity.len() || spectrum .mz @@ -364,9 +394,17 @@ fn validate_spectrum(stream: AcquisitionStreamId, spectrum: &MassSpectrum) -> Re )); } if let Some(precursor) = &spectrum.precursor - && (!precursor.selected_mz.is_finite() - || precursor.selected_mz <= 0.0 + && (precursor.source_spectrum_native_id.as_deref() == Some("") + || precursor + .selected_mz + .is_some_and(|v| !v.is_finite() || v <= 0.0) + || precursor + .selected_intensity + .is_some_and(|v| !v.is_finite() || v < 0.0) || precursor.charge == Some(0) + || precursor + .isolation_window_target_mz + .is_some_and(|v| !v.is_finite() || v <= 0.0) || precursor .isolation_window_lower_offset .is_some_and(|v| !v.is_finite() || v < 0.0) @@ -397,14 +435,20 @@ mod tests { ms_level: 2, polarity: Polarity::Positive, representation: SpectrumRepresentation::Centroid, + acquisition: SpectrumAcquisition::default(), mz: vec![100.0], intensity: vec![5.0], tic: 5.0, + tic_provenance: SpectrumSummaryProvenance::Derived, base_peak_mz: Some(100.0), base_peak_intensity: Some(5.0), + base_peak_provenance: SpectrumSummaryProvenance::Derived, precursor: Some(Precursor { - selected_mz: 445.2, + source_spectrum_native_id: Some("scan=4".to_owned()), + selected_mz: Some(445.2), + selected_intensity: Some(50.0), charge: Some(2), + isolation_window_target_mz: Some(445.0), isolation_window_lower_offset: Some(0.5), isolation_window_upper_offset: Some(0.5), collision_energy: Some(20.0), diff --git a/crates/io/src/mzml.rs b/crates/io/src/mzml.rs index 6ef1ade..cc842e0 100644 --- a/crates/io/src/mzml.rs +++ b/crates/io/src/mzml.rs @@ -5,9 +5,9 @@ //! and converted arrays for the spectrum currently being parsed. use crate::{ - Acquisition, AcquisitionStream, AcquisitionStreamId, DataFormat, IoError, LoadResult, - MassSpecRun, MassSpectrometryFormat, MassSpectrum, Polarity, Provenance, SpectrumId, - SpectrumRepresentation, StreamRole, + Acquisition, DataFormat, IoError, LoadResult, MassSpecRun, MassSpectrometryFormat, + MassSpectrum, Polarity, Provenance, SpectrumAcquisition, SpectrumId, SpectrumRepresentation, + SpectrumSummaryProvenance, }; use base64::Engine as _; use flate2::{Decompress, FlushDecompress, Status}; @@ -24,6 +24,10 @@ use std::{ #[path = "mzml_chromatogram.rs"] mod chromatogram; +#[path = "mzml_precursor.rs"] +mod precursor; +#[path = "mzml_stream.rs"] +mod stream; const MAX_SPECTRA: usize = 1_000_000; const MAX_POINTS_PER_SPECTRUM: usize = 5_000_000; @@ -87,9 +91,6 @@ impl BufRead for EventBounded { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct StreamKey(u8, u8); - #[derive(Default)] pub(super) struct BinaryArray { pub(super) kind: Option, @@ -120,6 +121,14 @@ struct SpectrumDraft { time_min: Option, polarity: Polarity, representation: SpectrumRepresentation, + instrument_configuration_id: Option, + source_event_id: Option, + filter_string: Option, + scan_count: usize, + tic: Option, + base_peak_mz: Option, + base_peak_intensity: Option, + precursor: precursor::Draft, mz: Option>, intensity: Option>, } @@ -159,6 +168,7 @@ pub fn parse(input: impl BufRead, source: String) -> Result Result { if spectra.len() >= MAX_SPECTRA { @@ -176,7 +188,7 @@ pub fn parse(input: impl BufRead, source: String) -> Result Result> = BTreeMap::new(); - for spectrum in spectra { - grouped - .entry(StreamKey( - spectrum.ms_level, - polarity_order(spectrum.polarity), - )) - .or_default() - .push(spectrum); - } - let streams = grouped - .into_iter() - .enumerate() - .map(|(index, (key, spectra))| { - let polarity = spectra[0].polarity; - AcquisitionStream { - id: AcquisitionStreamId::new(index as u64 + 1), - source_native_id: None, - source_label: Some(format!("MS{} {}", key.0, polarity_label(polarity))), - role: StreamRole::Primary, - acquisition_range: range(&spectra), - spectra, - } - }) - .collect(); + let streams = stream::build(spectra); let mut metadata = BTreeMap::new(); metadata.insert("source format".to_owned(), "mzML".to_owned()); if let Some(id) = run_id { @@ -259,7 +247,10 @@ pub fn parse(input: impl BufRead, source: String) -> Result) -> Result { +fn spectrum_draft( + tag: &BytesStart<'_>, + default_instrument_configuration_id: Option, +) -> Result { let native_id = attribute(tag, b"id")?; let declared_len = attribute(tag, b"defaultArrayLength")? .map(|value| { @@ -284,6 +275,14 @@ fn spectrum_draft(tag: &BytesStart<'_>) -> Result { time_min: None, polarity: Polarity::Unknown, representation: SpectrumRepresentation::Unknown, + instrument_configuration_id: default_instrument_configuration_id, + source_event_id: None, + filter_string: None, + scan_count: 0, + tic: None, + base_peak_mz: None, + base_peak_intensity: None, + precursor: precursor::Draft::default(), mz: None, intensity: None, }) @@ -304,22 +303,33 @@ fn parse_spectrum( let mut binary: Option = None; loop { match read_event(reader, buffer, MAX_XML_EVENT_BYTES)? { - Event::Start(tag) if tag.local_name().as_ref() == b"binaryDataArray" => { - binary = Some(BinaryArray::default()) - } - Event::Empty(tag) if tag.local_name().as_ref() == b"cvParam" => { - apply_cv(&tag, &mut draft, binary.as_mut())?; - } - Event::Start(tag) if tag.local_name().as_ref() == b"cvParam" => { - apply_cv(&tag, &mut draft, binary.as_mut())?; + Event::Start(tag) => { + draft.precursor.start(&tag)?; + if tag.local_name().as_ref() == b"scan" { + start_scan(&tag, &mut draft, &label, warnings)?; + } else if tag.local_name().as_ref() == b"cvParam" { + apply_cv(&tag, &mut draft, binary.as_mut())?; + draft.precursor.apply_cv(&tag)?; + } else if tag.local_name().as_ref() == b"binaryDataArray" { + binary = Some(BinaryArray::default()); + } else if tag.local_name().as_ref() == b"binary" { + let array = binary.as_mut().ok_or_else(|| { + invalid(format!( + "spectrum {label} has binary outside binaryDataArray" + )) + })?; + read_binary_text(reader, buffer, &mut array.text, &label)?; + } } - Event::Start(tag) if tag.local_name().as_ref() == b"binary" => { - let array = binary.as_mut().ok_or_else(|| { - invalid(format!( - "spectrum {label} has binary outside binaryDataArray" - )) - })?; - read_binary_text(reader, buffer, &mut array.text, &label)?; + Event::Empty(tag) => { + draft.precursor.start(&tag)?; + if tag.local_name().as_ref() == b"scan" { + start_scan(&tag, &mut draft, &label, warnings)?; + } else if tag.local_name().as_ref() == b"cvParam" { + apply_cv(&tag, &mut draft, binary.as_mut())?; + draft.precursor.apply_cv(&tag)?; + } + draft.precursor.end(tag.local_name().as_ref()); } Event::End(tag) if tag.local_name().as_ref() == b"binaryDataArray" => { let array = binary.take().ok_or_else(|| { @@ -345,6 +355,7 @@ fn parse_spectrum( } } Event::End(tag) if tag.local_name().as_ref() == b"spectrum" => break, + Event::End(tag) => draft.precursor.end(tag.local_name().as_ref()), Event::Eof => return Err(invalid(format!("input truncated inside spectrum {label}"))), _ => {} } @@ -385,7 +396,45 @@ fn parse_spectrum( ); 0.0 }); - let (tic, base_peak_mz, base_peak_intensity) = summaries(&mz, &intensity); + let (derived_tic, derived_base_peak_mz, derived_base_peak_intensity) = + summaries(&mz, &intensity); + let (tic, tic_provenance) = draft + .tic + .map_or((derived_tic, SpectrumSummaryProvenance::Derived), |value| { + (value, SpectrumSummaryProvenance::Source) + }); + let (base_peak_mz, base_peak_intensity, base_peak_provenance) = match ( + draft.base_peak_mz, + draft.base_peak_intensity, + ) { + (Some(mz), Some(intensity)) => { + (Some(mz), Some(intensity), SpectrumSummaryProvenance::Source) + } + (None, None) => ( + derived_base_peak_mz, + derived_base_peak_intensity, + SpectrumSummaryProvenance::Derived, + ), + _ => { + push_warning( + warnings, + format!( + "Spectrum {label} has incomplete source base-peak metadata; it was derived from the intensity array." + ), + ); + ( + derived_base_peak_mz, + derived_base_peak_intensity, + SpectrumSummaryProvenance::Derived, + ) + } + }; + let acquisition = SpectrumAcquisition { + instrument_configuration_id: draft.instrument_configuration_id, + source_event_id: draft.source_event_id, + filter_string: draft.filter_string, + }; + let precursor = draft.precursor.finish(&label, warnings); Ok(MassSpectrum { id: SpectrumId::new(ordinal as u64 + 1), source_native_id: draft.native_id, @@ -393,15 +442,45 @@ fn parse_spectrum( ms_level, polarity: draft.polarity, representation: draft.representation, + acquisition, mz, intensity, tic, + tic_provenance, base_peak_mz, base_peak_intensity, - precursor: None, + base_peak_provenance, + precursor, }) } +fn start_scan( + tag: &BytesStart<'_>, + draft: &mut SpectrumDraft, + label: &str, + warnings: &mut Vec, +) -> Result<(), IoError> { + draft.scan_count += 1; + if draft.scan_count == 1 { + if let Some(reference) = attribute(tag, b"instrumentConfigurationRef")? { + if reference.is_empty() { + return Err(invalid(format!( + "spectrum {label} has an empty instrumentConfigurationRef" + ))); + } + draft.instrument_configuration_id = Some(reference); + } + } else if draft.scan_count == 2 { + push_warning( + warnings, + format!( + "Spectrum {label} describes multiple scans; only the first scan's acquisition metadata was imported." + ), + ); + } + Ok(()) +} + fn apply_cv( tag: &BytesStart<'_>, draft: &mut SpectrumDraft, @@ -438,7 +517,24 @@ fn apply_cv( "MS:1000129" => draft.polarity = Polarity::Negative, "MS:1000127" => draft.representation = SpectrumRepresentation::Centroid, "MS:1000128" => draft.representation = SpectrumRepresentation::Profile, - "MS:1000016" => { + "MS:1000285" => draft.tic = Some(nonnegative_value(tag, "total ion current")?), + "MS:1000504" => draft.base_peak_mz = Some(nonnegative_value(tag, "base peak m/z")?), + "MS:1000505" => { + draft.base_peak_intensity = Some(nonnegative_value(tag, "base peak intensity")?) + } + "MS:1000512" if draft.scan_count == 1 => { + draft.filter_string = attribute(tag, b"value")?.filter(|value| !value.is_empty()) + } + "MS:1000616" if draft.scan_count == 1 => { + let value = attribute(tag, b"value")? + .ok_or_else(|| invalid("preset scan configuration has no value"))?; + draft.source_event_id = Some( + value + .parse::() + .map_err(|_| invalid("invalid preset scan configuration"))?, + ); + } + "MS:1000016" if draft.scan_count == 1 => { let value = attribute(tag, b"value")? .ok_or_else(|| invalid("scan start time has no value"))? .parse::() @@ -458,6 +554,17 @@ fn apply_cv( Ok(()) } +fn nonnegative_value(tag: &BytesStart<'_>, field: &str) -> Result { + let value = attribute(tag, b"value")? + .ok_or_else(|| invalid(format!("{field} has no value")))? + .parse::() + .map_err(|_| invalid(format!("invalid {field}")))?; + if !value.is_finite() || value < 0.0 { + return Err(invalid(format!("invalid {field}"))); + } + Ok(value) +} + pub(super) fn read_binary_text( reader: &mut Reader>, buffer: &mut Vec, @@ -663,32 +770,15 @@ fn decompress_zlib_exact(compressed: &[u8], label: &str) -> Result, IoEr } fn summaries(mz: &[f64], intensity: &[f64]) -> (f64, Option, Option) { - let tic = intensity.iter().sum(); + let tic = intensity.iter().sum::().max(0.0); let base = intensity .iter() .enumerate() + .filter(|(_, value)| **value >= 0.0) .max_by(|a, b| a.1.total_cmp(b.1)); (tic, base.map(|(i, _)| mz[i]), base.map(|(_, v)| *v)) } -fn range(spectra: &[MassSpectrum]) -> Option<[f64; 2]> { - let mut values = spectra.iter().flat_map(|s| s.mz.iter().copied()); - let first = values.next()?; - Some(values.fold([first, first], |[lo, hi], v| [lo.min(v), hi.max(v)])) -} -fn polarity_order(p: Polarity) -> u8 { - match p { - Polarity::Positive => 0, - Polarity::Negative => 1, - Polarity::Unknown => 2, - } -} -fn polarity_label(p: Polarity) -> &'static str { - match p { - Polarity::Positive => "positive", - Polarity::Negative => "negative", - Polarity::Unknown => "unknown polarity", - } -} + pub(super) fn push_warning(warnings: &mut Vec, message: String) { if warnings.len() + 1 < MAX_IMPORT_WARNINGS { warnings.push(message); diff --git a/crates/io/src/mzml_precursor.rs b/crates/io/src/mzml_precursor.rs new file mode 100644 index 0000000..6bff382 --- /dev/null +++ b/crates/io/src/mzml_precursor.rs @@ -0,0 +1,211 @@ +use super::{attribute, invalid, push_warning}; +use crate::{IoError, Precursor}; +use quick_xml::events::BytesStart; + +#[derive(Clone, Copy, Default)] +enum Section { + #[default] + Other, + IsolationWindow, + SelectedIon(usize), + Activation, +} + +#[derive(Default)] +pub(super) struct Draft { + precursor_index: Option, + precursor_count: usize, + selected_ion_count: usize, + section: Section, + source_spectrum_native_id: Option, + selected_mz: Option, + selected_intensity: Option, + charge: Option, + isolation_window_target_mz: Option, + isolation_window_lower_offset: Option, + isolation_window_upper_offset: Option, + collision_energy: Option, + activation_methods: Vec, +} + +impl Draft { + pub(super) fn start(&mut self, tag: &BytesStart<'_>) -> Result<(), IoError> { + match tag.local_name().as_ref() { + b"precursor" => { + self.precursor_count += 1; + self.precursor_index = Some(self.precursor_count); + self.section = Section::Other; + if self.precursor_count == 1 { + self.source_spectrum_native_id = attribute(tag, b"spectrumRef")?; + } + } + b"isolationWindow" if self.precursor_index.is_some() => { + self.section = Section::IsolationWindow; + } + b"selectedIon" if self.precursor_index == Some(1) => { + self.selected_ion_count += 1; + self.section = Section::SelectedIon(self.selected_ion_count); + } + b"activation" if self.precursor_index.is_some() => { + self.section = Section::Activation; + } + _ => {} + } + Ok(()) + } + + pub(super) fn end(&mut self, name: &[u8]) { + match name { + b"precursor" => { + self.precursor_index = None; + self.section = Section::Other; + } + b"isolationWindow" | b"selectedIon" | b"activation" => { + self.section = Section::Other; + } + _ => {} + } + } + + pub(super) fn apply_cv(&mut self, tag: &BytesStart<'_>) -> Result<(), IoError> { + if self.precursor_index != Some(1) { + return Ok(()); + } + let accession = attribute(tag, b"accession")?.unwrap_or_default(); + match (self.section, accession.as_str()) { + (Section::IsolationWindow, "MS:1000827") => set_f64( + &mut self.isolation_window_target_mz, + tag, + "isolation window target m/z", + ), + (Section::IsolationWindow, "MS:1000828") => set_f64( + &mut self.isolation_window_lower_offset, + tag, + "isolation window lower offset", + ), + (Section::IsolationWindow, "MS:1000829") => set_f64( + &mut self.isolation_window_upper_offset, + tag, + "isolation window upper offset", + ), + (Section::SelectedIon(1), "MS:1000744" | "MS:1000040") => { + set_f64(&mut self.selected_mz, tag, "selected ion m/z") + } + (Section::SelectedIon(1), "MS:1000042") => { + set_f64(&mut self.selected_intensity, tag, "selected ion intensity") + } + (Section::SelectedIon(1), "MS:1000041") => { + let value = required_value(tag, "charge state")?; + set_once( + &mut self.charge, + value + .parse::() + .map_err(|_| invalid("invalid precursor charge state"))?, + "precursor charge state", + ) + } + (Section::Activation, "MS:1000045") => { + set_f64(&mut self.collision_energy, tag, "collision energy") + } + (Section::Activation, accession) if !is_activation_energy(accession) => { + if let Some(name) = attribute(tag, b"name")?.filter(|name| !name.is_empty()) + && !self.activation_methods.contains(&name) + { + self.activation_methods.push(name); + } + Ok(()) + } + _ => Ok(()), + } + } + + pub(super) fn finish(self, spectrum: &str, warnings: &mut Vec) -> Option { + if self.precursor_count > 1 { + push_warning( + warnings, + format!( + "Spectrum {spectrum} has {} precursors; only the first was imported.", + self.precursor_count + ), + ); + } + if self.selected_ion_count > 1 { + push_warning( + warnings, + format!( + "Spectrum {spectrum} has {} selected ions in its first precursor; only the first was imported.", + self.selected_ion_count + ), + ); + } + if self.precursor_count == 0 { + return None; + } + let activation_method = + (!self.activation_methods.is_empty()).then(|| self.activation_methods.join(" + ")); + let precursor = Precursor { + source_spectrum_native_id: self.source_spectrum_native_id, + selected_mz: self.selected_mz, + selected_intensity: self.selected_intensity, + charge: self.charge, + isolation_window_target_mz: self.isolation_window_target_mz, + isolation_window_lower_offset: self.isolation_window_lower_offset, + isolation_window_upper_offset: self.isolation_window_upper_offset, + collision_energy: self.collision_energy, + activation_method, + }; + if precursor.source_spectrum_native_id.is_none() + && precursor.selected_mz.is_none() + && precursor.selected_intensity.is_none() + && precursor.charge.is_none() + && precursor.isolation_window_target_mz.is_none() + && precursor.isolation_window_lower_offset.is_none() + && precursor.isolation_window_upper_offset.is_none() + && precursor.collision_energy.is_none() + && precursor.activation_method.is_none() + { + push_warning( + warnings, + format!( + "Spectrum {spectrum} has a precursor with no supported metadata; it was skipped." + ), + ); + None + } else { + Some(precursor) + } + } +} + +fn required_value(tag: &BytesStart<'_>, field: &str) -> Result { + attribute(tag, b"value")?.ok_or_else(|| invalid(format!("{field} has no value"))) +} + +fn set_f64(target: &mut Option, tag: &BytesStart<'_>, field: &str) -> Result<(), IoError> { + let value = required_value(tag, field)? + .parse::() + .map_err(|_| invalid(format!("invalid {field}")))?; + set_once(target, value, field) +} + +fn set_once(target: &mut Option, value: T, field: &str) -> Result<(), IoError> { + if target.replace(value).is_some() { + return Err(invalid(format!("precursor repeats {field}"))); + } + Ok(()) +} + +fn is_activation_energy(accession: &str) -> bool { + matches!( + accession, + "MS:1000045" + | "MS:1000138" + | "MS:1000509" + | "MS:1002013" + | "MS:1002014" + | "MS:1002218" + | "MS:1002219" + | "MS:1002680" + | "MS:1003410" + ) +} diff --git a/crates/io/src/mzml_stream.rs b/crates/io/src/mzml_stream.rs new file mode 100644 index 0000000..937332c --- /dev/null +++ b/crates/io/src/mzml_stream.rs @@ -0,0 +1,54 @@ +use crate::{AcquisitionStream, AcquisitionStreamId, MassSpectrum, Polarity, StreamRole}; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct StreamKey(u8, u8); + +pub(super) fn build(spectra: Vec) -> Vec { + let mut grouped: BTreeMap> = BTreeMap::new(); + for spectrum in spectra { + let key = StreamKey(spectrum.ms_level, polarity_order(spectrum.polarity)); + grouped.entry(key).or_default().push(spectrum); + } + grouped + .into_iter() + .enumerate() + .map(|(index, (key, spectra))| { + let polarity = spectra[0].polarity; + AcquisitionStream { + id: AcquisitionStreamId::new(index as u64 + 1), + source_native_id: None, + source_label: Some(format!("MS{} {}", key.0, polarity_label(polarity))), + role: StreamRole::Primary, + acquisition_range: range(&spectra), + spectra, + } + }) + .collect() +} + +fn range(spectra: &[MassSpectrum]) -> Option<[f64; 2]> { + let mut values = spectra + .iter() + .flat_map(|spectrum| spectrum.mz.iter().copied()); + let first = values.next()?; + Some(values.fold([first, first], |[low, high], value| { + [low.min(value), high.max(value)] + })) +} + +fn polarity_order(polarity: Polarity) -> u8 { + match polarity { + Polarity::Positive => 0, + Polarity::Negative => 1, + Polarity::Unknown => 2, + } +} + +fn polarity_label(polarity: Polarity) -> &'static str { + match polarity { + Polarity::Positive => "positive", + Polarity::Negative => "negative", + Polarity::Unknown => "unknown polarity", + } +} diff --git a/crates/io/src/mzml_tests.rs b/crates/io/src/mzml_tests.rs index 71159a1..98f027a 100644 --- a/crates/io/src/mzml_tests.rs +++ b/crates/io/src/mzml_tests.rs @@ -1,6 +1,8 @@ use super::*; +use crate::AcquisitionStreamId; use flate2::{Compression, write::ZlibEncoder}; use std::io::{self, BufReader, Cursor, Read, Write}; +use std::path::Path; struct ChunkedRead { inner: R, @@ -141,6 +143,259 @@ fn imports_zlib_f32_into_f64() { assert_eq!(run.streams[0].spectra[0].polarity, Polarity::Negative); } +#[test] +fn prefers_source_tic_and_base_peak_over_profile_array_summaries() { + let source_summaries = concat!( + "", + "", + "" + ); + let spectrum = spectrum("scan=1", 1, "MS:1000130", false, TestPrecision::F64, false).replace( + "", + &format!("{source_summaries}"), + ); + let run = parsed(document(&spectrum)); + let scan = &run.streams[0].spectra[0]; + + assert_eq!(scan.intensity.iter().sum::(), 40.0); + assert_eq!(scan.tic, 12.5); + assert_eq!(scan.tic_provenance, SpectrumSummaryProvenance::Source); + assert_eq!(scan.base_peak_mz, Some(150.25)); + assert_eq!(scan.base_peak_intensity, Some(8.5)); + assert_eq!(scan.base_peak_provenance, SpectrumSummaryProvenance::Source); +} + +#[test] +fn retains_acquisition_metadata_without_fragmenting_ms_level_streams() { + let ftms = spectrum( + "scan=1", + 1, + "MS:1000130", + false, + TestPrecision::F64, + false, + ) + .replace( + "", + "", + ); + let itms = spectrum( + "scan=2", + 1, + "MS:1000130", + false, + TestPrecision::F64, + false, + ) + .replace( + "", + "", + ); + let ms2 = |id: &str, filter: &str| { + spectrum( + id, + 2, + "MS:1000130", + false, + TestPrecision::F64, + false, + ) + .replace( + "", + &format!( + "" + ), + ) + }; + let spectra = + ftms + &itms + &ms2("scan=3", "MS2 precursor 445") + &ms2("scan=4", "MS2 precursor 500"); + let xml = document(&spectra).replace( + "", + "", + ); + let run = parsed(xml); + + assert_eq!(run.streams.len(), 2); + assert_eq!(run.streams[0].source_label.as_deref(), Some("MS1 positive")); + assert_eq!(run.streams[1].source_label.as_deref(), Some("MS2 positive")); + assert_eq!(run.streams[0].spectra.len(), 2); + assert_eq!(run.streams[1].spectra.len(), 2); + assert_eq!( + run.streams[0].spectra[0] + .acquisition + .instrument_configuration_id + .as_deref(), + Some("IC1") + ); + assert_eq!( + run.streams[0].spectra[1].acquisition.source_event_id, + Some(2) + ); + assert_eq!( + run.streams[1].spectra[1] + .acquisition + .filter_string + .as_deref(), + Some("MS2 precursor 500") + ); +} + +#[test] +fn local_small_fixture_matches_source_tic_and_acquisition_functions_when_present() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(".tmp/MS-data/hupo-psi-mzpeak-small/small.mzML"); + if !path.is_file() { + return; + } + let loaded = load(&path).unwrap(); + let Acquisition::MassSpec(run) = loaded.acquisition else { + panic!("small.mzML did not import as mass spectrometry data"); + }; + + assert!(run.import_warnings.is_empty()); + assert_eq!(run.streams.len(), 2); + assert_eq!( + run.streams + .iter() + .map(|stream| stream.spectra.len()) + .collect::>(), + [14, 34] + ); + assert_eq!( + run.streams[0] + .spectra + .iter() + .map(|spectrum| [spectrum.retention_time_min, spectrum.tic]) + .collect::>(), + [ + [0.004935, 15_245_068.0], + [0.007896666667, 12_901_166.0], + [0.075015, 15_148_302.0], + [0.077788333333, 10_349_958.0], + [0.143451666667, 18_257_344.0], + [0.146408333333, 11_037_852.0], + [0.213673333333, 17_613_074.0], + [0.216746666667, 1_597_410.5], + [0.285483333333, 22_136_832.0], + [0.288898333333, 12_434_530.0], + [0.358558333333, 16_495_375.0], + [0.361428333333, 6_548_706.5], + [0.428483333333, 12_015_003.0], + [0.433221666667, 13_332_331.0], + ] + ); + assert_eq!( + run.streams + .iter() + .flat_map(|stream| &stream.spectra) + .filter(|spectrum| spectrum.precursor.is_some()) + .count(), + 34 + ); + let scan = run + .streams + .iter() + .flat_map(|stream| &stream.spectra) + .find(|spectrum| { + spectrum.source_native_id.as_deref() + == Some("controllerType=0 controllerNumber=1 scan=29") + }) + .unwrap(); + assert_eq!(scan.retention_time_min, 0.285483333333); + assert_eq!(scan.tic, 22_136_832.0); + assert_eq!(scan.tic_provenance, SpectrumSummaryProvenance::Source); + assert_eq!( + scan.acquisition.instrument_configuration_id.as_deref(), + Some("IC1") + ); + assert_eq!(scan.acquisition.source_event_id, Some(1)); +} + +#[test] +fn imports_ms2_selected_ion_isolation_window_and_activation() { + let details = concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ); + let spectrum = spectrum("scan=2", 2, "MS:1000130", false, TestPrecision::F64, false) + .replace("", &format!("{details}")); + let run = parsed(document(&spectrum)); + + assert!(run.import_warnings.is_empty()); + let precursor = run.streams[0].spectra[0].precursor.as_ref().unwrap(); + assert_eq!( + precursor.source_spectrum_native_id.as_deref(), + Some("scan=1") + ); + assert_eq!(precursor.selected_mz, Some(445.34)); + assert_eq!(precursor.selected_intensity, Some(1_994_039.0)); + assert_eq!(precursor.charge, Some(2)); + assert_eq!(precursor.isolation_window_target_mz, Some(445.35)); + assert_eq!(precursor.isolation_window_lower_offset, Some(0.6)); + assert_eq!(precursor.isolation_window_upper_offset, Some(0.8)); + assert_eq!(precursor.collision_energy, Some(27.5)); + assert_eq!( + precursor.activation_method.as_deref(), + Some("beam-type collision-induced dissociation") + ); +} + +#[test] +fn imports_self_closing_precursor_reference() { + let spectrum = spectrum("scan=2", 2, "MS:1000130", false, TestPrecision::F64, false).replace( + "", + "", + ); + let run = parsed(document(&spectrum)); + + assert!(run.import_warnings.is_empty()); + let precursor = run.streams[0].spectra[0].precursor.as_ref().unwrap(); + assert_eq!( + precursor.source_spectrum_native_id.as_deref(), + Some("scan=1") + ); + assert_eq!(precursor.selected_mz, None); + assert_eq!(precursor.isolation_window_target_mz, None); +} + +#[test] +fn keeps_dia_isolation_target_separate_and_warns_about_extra_precursors() { + let details = concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "" + ); + let spectrum = spectrum("dia=1", 2, "MS:1000129", false, TestPrecision::F64, false) + .replace("", &format!("{details}")); + let run = parsed(document(&spectrum)); + + let precursor = run.streams[0].spectra[0].precursor.as_ref().unwrap(); + assert_eq!(precursor.selected_mz, None); + assert_eq!(precursor.isolation_window_target_mz, Some(500.0)); + assert_eq!(precursor.isolation_window_lower_offset, Some(12.5)); + assert_eq!(run.import_warnings.len(), 2); + assert!(run.import_warnings[0].contains("2 precursors")); + assert!(run.import_warnings[1].contains("2 selected ions")); +} + #[test] fn imports_chromatogram_only_tic_and_structured_srm_transition() { let tic = chromatogram("TIC", "MS:1000235", ""); diff --git a/crates/io/src/sciex_wiff.rs b/crates/io/src/sciex_wiff.rs index 3e4c940..ee270c4 100644 --- a/crates/io/src/sciex_wiff.rs +++ b/crates/io/src/sciex_wiff.rs @@ -2,7 +2,7 @@ use crate::{ Acquisition, AcquisitionStream, AcquisitionStreamId, DataFormat, IoError, LoadResult, LoadWarning, LoadWarningCode, MassSpecRun, MassSpectrometryFormat, MassSpectrum, Polarity, - Precursor, Provenance, SpectrumId, SpectrumRepresentation, StreamRole, + Precursor, Provenance, SpectrumAcquisition, SpectrumId, SpectrumRepresentation, StreamRole, }; use byteorder::{ByteOrder, LittleEndian}; use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; @@ -13,6 +13,8 @@ use std::path::Path; #[path = "sciex_wiff_scan.rs"] mod scan; use scan::{companion_path, decode_scan_block}; +#[path = "sciex_wiff_summaries.rs"] +mod summaries; #[path = "sciex_wiff_tic.rs"] mod tic; struct StreamBuilder { @@ -716,29 +718,26 @@ fn convert_spectrum( .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 summaries::Summaries { + tic, + tic_provenance, + base_peak_mz, + base_peak_intensity, + base_peak_provenance, + } = summaries::resolve(&record, &intensity); + let precursor = record.precursor.map(|source| { let half_width = source.isolation_width.map(|width| width / 2.0); - Some(Precursor { - selected_mz, + Precursor { + source_spectrum_native_id: None, + selected_mz: source.selected_mz, + selected_intensity: None, charge: source.charge, + isolation_window_target_mz: source.target_mz, 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 { @@ -752,11 +751,18 @@ fn convert_spectrum( Some(ScanMode::Profile) => SpectrumRepresentation::Profile, None => SpectrumRepresentation::Unknown, }, + acquisition: SpectrumAcquisition { + instrument_configuration_id: None, + source_event_id: record.acquisition_event_id, + filter_string: record.filter.filter(|value| !value.is_empty()), + }, mz: record.mz, intensity, tic, + tic_provenance, base_peak_mz, base_peak_intensity, + base_peak_provenance, precursor, }) } diff --git a/crates/io/src/sciex_wiff_summaries.rs b/crates/io/src/sciex_wiff_summaries.rs new file mode 100644 index 0000000..1531970 --- /dev/null +++ b/crates/io/src/sciex_wiff_summaries.rs @@ -0,0 +1,52 @@ +use super::SpectrumRecord; +use crate::SpectrumSummaryProvenance; + +pub(super) struct Summaries { + pub(super) tic: f64, + pub(super) tic_provenance: SpectrumSummaryProvenance, + pub(super) base_peak_mz: Option, + pub(super) base_peak_intensity: Option, + pub(super) base_peak_provenance: SpectrumSummaryProvenance, +} + +pub(super) fn resolve(record: &SpectrumRecord, intensity: &[f64]) -> Summaries { + let (tic, tic_provenance) = record + .total_ion_current + .filter(|value| value.is_finite() && *value >= 0.0) + .map_or_else( + || { + ( + intensity.iter().copied().sum::().max(0.0), + SpectrumSummaryProvenance::Derived, + ) + }, + |value| (value, SpectrumSummaryProvenance::Source), + ); + let derived_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_provenance) = record + .base_peak_mz + .zip(record.base_peak_intensity) + .filter(|(mz, intensity)| { + mz.is_finite() && *mz >= 0.0 && intensity.is_finite() && *intensity >= 0.0 + }) + .map_or_else( + || { + let (mz, intensity) = derived_base_peak.map_or((None, None), |(index, value)| { + (record.mz.get(index).copied(), Some(*value)) + }); + (mz, intensity, SpectrumSummaryProvenance::Derived) + }, + |(mz, intensity)| (Some(mz), Some(intensity), SpectrumSummaryProvenance::Source), + ); + Summaries { + tic, + tic_provenance, + base_peak_mz, + base_peak_intensity, + base_peak_provenance, + } +} diff --git a/crates/io/src/sciex_wiff_tests.rs b/crates/io/src/sciex_wiff_tests.rs index 584dad9..2d915c1 100644 --- a/crates/io/src/sciex_wiff_tests.rs +++ b/crates/io/src/sciex_wiff_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::SpectrumSummaryProvenance; use std::collections::BTreeMap; use std::io::Write; use std::path::PathBuf; @@ -235,8 +236,13 @@ fn loads_all_samples_as_distinct_streams_and_chromatograms() { #[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 mut source = source_spectrum(); + source.total_ion_current = Some(19.0); + source.base_peak_mz = Some(100.0); + source.base_peak_intensity = Some(4.0); + source.acquisition_event_id = Some(2); + source.filter = Some("TOF MS2".to_owned()); + let streams = build_streams(vec![source], "Sample1", &mut 1, &mut Vec::new()).unwrap(); let stream = &streams[0]; let spectrum = &stream.spectra[0]; @@ -255,12 +261,25 @@ fn maps_spectrum_identity_time_polarity_precursor_and_summaries() { 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)); + assert_eq!(spectrum.acquisition.source_event_id, Some(2)); + assert_eq!( + spectrum.acquisition.filter_string.as_deref(), + Some("TOF MS2") + ); + assert_eq!(spectrum.tic, 19.0); + assert_eq!(spectrum.tic_provenance, SpectrumSummaryProvenance::Source); + assert_eq!(spectrum.base_peak_mz, Some(100.0)); + assert_eq!(spectrum.base_peak_intensity, Some(4.0)); + assert_eq!( + spectrum.base_peak_provenance, + SpectrumSummaryProvenance::Source + ); let precursor = spectrum.precursor.as_ref().unwrap(); - assert_eq!(precursor.selected_mz, 445.34); + assert_eq!(precursor.source_spectrum_native_id, None); + assert_eq!(precursor.selected_mz, Some(445.34)); + assert_eq!(precursor.selected_intensity, None); assert_eq!(precursor.charge, Some(2)); + assert_eq!(precursor.isolation_window_target_mz, Some(445.35)); 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)); diff --git a/crates/io/src/waters.rs b/crates/io/src/waters.rs index 0af57e9..0c83429 100644 --- a/crates/io/src/waters.rs +++ b/crates/io/src/waters.rs @@ -4,7 +4,7 @@ use crate::{ Acquisition, AcquisitionStream, AcquisitionStreamId, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, DataFormat, IoError, LoadResult, LoadWarning, LoadWarningCode, MassSpecRun, MassSpectrometryFormat, MassSpectrum, Polarity, Provenance, - SpectrumId, SpectrumRepresentation, StreamRole, + SpectrumAcquisition, SpectrumId, SpectrumRepresentation, SpectrumSummaryProvenance, StreamRole, }; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; @@ -551,11 +551,14 @@ fn decode_low_resolution6( ms_level: 1, polarity, representation: SpectrumRepresentation::Unknown, + acquisition: SpectrumAcquisition::default(), mz: coordinates, intensity: values, tic, + tic_provenance: SpectrumSummaryProvenance::Derived, base_peak_mz, base_peak_intensity, + base_peak_provenance: SpectrumSummaryProvenance::Derived, precursor: None, }); } diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index f32a6d1..ec58db7 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -61,10 +61,24 @@ non-uniform sampling, and other arrayed experiments are not supported. See Open or drop a `.mzML` file. PlotX imports the spectra into the same LC–MS dataset and chart workflow used for Waters runs. Spectra are grouped by MS -level and polarity; scan times recorded in seconds or minutes are displayed in -minutes. PlotX also imports file-supplied TIC, BPC, SIM, and SRM chromatograms, -including chromatogram-only acquisitions. Transition precursor/product m/z, -polarity, collision energy, and activation method are retained when present. +level and polarity, while each scan retains acquisition-function metadata such +as its mzML instrument configuration, preset scan configuration, and filter +string when present. Scan times recorded in seconds or minutes are displayed +in minutes. PlotX prefers +each spectrum's file-supplied TIC and base-peak summaries over values derived +from profile samples, and retains whether each summary came from the source or +was derived. PlotX also imports file-supplied TIC, BPC, SIM, and SRM +chromatograms, including chromatogram-only acquisitions. Transition +precursor/product m/z, polarity, collision energy, and activation method are +retained when present. +For MS2 and higher spectra, PlotX separately retains the precursor spectrum +reference, selected-ion m/z, selected-ion intensity and charge, isolation-window +target and offsets, collision energy, and activation method. This distinction +preserves DIA isolation targets even when a selected ion is not present. The +current data model stores one precursor and one selected ion per spectrum; an +import warning identifies spectra with additional values and states that only +the first was retained. Scientific Script scan snapshots expose the summary +provenance, instrument configuration, source event or preset, and filter string. The importer accepts little-endian 32-bit and 64-bit floating-point m/z, time, and intensity arrays with no compression or zlib compression. Numpress, 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 5eaede7..61ff2c2 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -52,10 +52,20 @@ CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.tx ## mzML 打开或拖入 `.mzML` 文件。PlotX 会将谱图导入与 Waters 数据相同的 LC–MS -数据集和图表工作流。谱图按 MS 级别和极性分组;以秒或分钟记录的扫描时间都会 -换算为分钟显示。PlotX 也会导入文件自带的 TIC、BPC、SIM 与 SRM 色谱图, -包括只有色谱图而没有 scan 谱图的采集。文件提供时,会保留 transition 的 +数据集和图表工作流。谱图按 MS 级别和极性分组;每张 scan 仍会保留文件中可用的 +采集功能元数据,例如 mzML instrument configuration、preset scan configuration 和 +filter string。以秒或分钟记录的 +扫描时间都会换算为分钟显示。对于每张谱图,PlotX 优先使用文件提供的 TIC 和 +base-peak 摘要,而不是对 profile 采样点简单求和;同时保留摘要来自源文件还是由数组派生。 +PlotX 也会导入文件自带的 TIC、BPC、SIM 与 SRM 色谱图,包括只有色谱图而没有 +scan 谱图的采集。文件提供时,会保留 transition 的 precursor/product m/z、极性、碰撞能量和活化方法。 +对于 MS2 及更高级谱图,PlotX 会分别保留 precursor 谱图引用、selected-ion m/z、 +selected-ion 强度与电荷、隔离窗目标与上下偏移、碰撞能量和活化方法。因此,即使 +DIA 没有 selected ion,其隔离窗目标也不会被误写成 selected-ion m/z。当前数据模型 +为每张谱图保存一个 precursor 和一个 selected ion;若文件包含更多值,导入警告会 +指出对应谱图,并说明只保留了第一个值。Scientific Script 的 scan 快照会暴露摘要 +provenance、instrument configuration、源 event 或 preset,以及 filter string。 导入器支持小端 32 位和 64 位浮点 m/z、时间与强度数组,可不压缩或使用 zlib 压缩。Numpress、大端数组以及缺少必需数组的谱图或色谱图会使导入停止并显示错误。