diff --git a/crates/app/src/ui/scientific_script.rs b/crates/app/src/ui/scientific_script.rs index 72da456..d4818de 100644 --- a/crates/app/src/ui/scientific_script.rs +++ b/crates/app/src/ui/scientific_script.rs @@ -101,6 +101,10 @@ pub(crate) fn prepare_run( serde_json::json!({ "id": channel.id.0, "kind": match channel.kind { + ChromatogramKind::TotalIonCurrent => "total_ion_current", + ChromatogramKind::BasePeak => "base_peak", + ChromatogramKind::SelectedIonMonitoring => "selected_ion_monitoring", + ChromatogramKind::SelectedReactionMonitoring => "selected_reaction_monitoring", ChromatogramKind::Optical => "optical", ChromatogramKind::Temperature => "temperature", ChromatogramKind::Pressure => "pressure", @@ -108,6 +112,17 @@ pub(crate) fn prepare_run( ChromatogramKind::Unknown => "unknown", }, "description": channel.description, + "polarity": match channel.polarity { + plotx_io::Polarity::Positive => "positive", + plotx_io::Polarity::Negative => "negative", + plotx_io::Polarity::Unknown => "unknown", + }, + "transition": channel.transition.as_ref().map(|transition| serde_json::json!({ + "precursor_mz": transition.precursor_mz, + "product_mz": transition.product_mz, + "collision_energy": transition.collision_energy, + "activation_method": transition.activation_method, + })), "coordinate": channel.coordinate, "unit": channel.unit, "time_min": channel.time_min, diff --git a/crates/app/src/ui/tools/mass_spec.rs b/crates/app/src/ui/tools/mass_spec.rs index 3b97a55..11a5307 100644 --- a/crates/app/src/ui/tools/mass_spec.rs +++ b/crates/app/src/ui/tools/mass_spec.rs @@ -44,6 +44,12 @@ pub(super) fn mass_spectrometry_group(app: &mut PlotxApp, di: usize, ui: &mut Ui let selected_spectrum = dataset.selected_spectrum().cloned(); let extraction_count = dataset.extracted_spectra.len(); let xic_count = dataset.extracted_ion_chromatograms.len(); + let transition_count = dataset + .run + .chromatograms + .iter() + .filter(|channel| channel.transition.is_some()) + .count(); ui.label(crate::typography::headline("Acquisition")); let active_label = streams @@ -69,6 +75,14 @@ pub(super) fn mass_spectrometry_group(app: &mut PlotxApp, di: usize, ui: &mut Ui if !optical.is_empty() { ui.weak(format!("Detector channels: {}", optical.join(", "))); } + if streams.is_empty() { + ui.label("Chromatogram-only acquisition"); + ui.weak(format!( + "{} channels · {transition_count} transitions", + dataset.run.chromatograms.len() + )); + return false; + } ui.separator(); ui.label(crate::typography::headline("Scan preview")); diff --git a/crates/core/src/project/mass_spec_convert.rs b/crates/core/src/project/mass_spec_convert.rs index a89e578..b22bb36 100644 --- a/crates/core/src/project/mass_spec_convert.rs +++ b/crates/core/src/project/mass_spec_convert.rs @@ -159,13 +159,26 @@ fn write_channel(output: &mut impl Write, channel: &ChromatogramChannel) -> Resu write_u8( output, match channel.kind { - ChromatogramKind::Optical => 0, - ChromatogramKind::Temperature => 1, - ChromatogramKind::Pressure => 2, - ChromatogramKind::Housekeeping => 3, - ChromatogramKind::Unknown => 4, + ChromatogramKind::TotalIonCurrent => 0, + ChromatogramKind::BasePeak => 1, + ChromatogramKind::SelectedIonMonitoring => 2, + ChromatogramKind::SelectedReactionMonitoring => 3, + ChromatogramKind::Optical => 4, + ChromatogramKind::Temperature => 5, + ChromatogramKind::Pressure => 6, + ChromatogramKind::Housekeeping => 7, + ChromatogramKind::Unknown => 8, }, )?; + write_u8( + output, + match channel.polarity { + Polarity::Positive => 0, + Polarity::Negative => 1, + Polarity::Unknown => 2, + }, + )?; + write_optional_transition(output, channel.transition.as_ref())?; write_optional_u64(output, channel.source_stream.map(AcquisitionStreamId::get))?; write_optional_f64(output, channel.coordinate)?; write_string(output, &channel.description)?; @@ -174,6 +187,20 @@ fn write_channel(output: &mut impl Write, channel: &ChromatogramChannel) -> Resu write_f64s(output, &channel.values) } +fn write_optional_transition( + output: &mut impl Write, + transition: Option<&plotx_io::MassTransition>, +) -> Result<()> { + let Some(transition) = transition else { + return write_u8(output, 0); + }; + write_u8(output, 1)?; + write_optional_f64(output, transition.precursor_mz)?; + write_optional_f64(output, transition.product_mz)?; + write_optional_f64(output, transition.collision_energy)?; + write_optional_string(output, transition.activation_method.as_deref()) +} + pub(super) fn decode(input: &mut EntryReader<'_, R>) -> Result { let mut reader = Reader::new(input); if reader.read_array::<8>()? != *MAGIC { @@ -577,13 +604,33 @@ impl<'a, 'p, R: Read> Reader<'a, 'p, R> { fn read_channel(&mut self) -> Result { let id = ChromatogramChannelId(self.read_string()?); let kind = match self.read_u8()? { - 0 => ChromatogramKind::Optical, - 1 => ChromatogramKind::Temperature, - 2 => ChromatogramKind::Pressure, - 3 => ChromatogramKind::Housekeeping, - 4 => ChromatogramKind::Unknown, + 0 => ChromatogramKind::TotalIonCurrent, + 1 => ChromatogramKind::BasePeak, + 2 => ChromatogramKind::SelectedIonMonitoring, + 3 => ChromatogramKind::SelectedReactionMonitoring, + 4 => ChromatogramKind::Optical, + 5 => ChromatogramKind::Temperature, + 6 => ChromatogramKind::Pressure, + 7 => ChromatogramKind::Housekeeping, + 8 => ChromatogramKind::Unknown, tag => return Err(invalid_tag("chromatogram kind", tag)), }; + let polarity = match self.read_u8()? { + 0 => Polarity::Positive, + 1 => Polarity::Negative, + 2 => Polarity::Unknown, + tag => return Err(invalid_tag("chromatogram polarity", tag)), + }; + let transition = match self.read_u8()? { + 0 => None, + 1 => Some(plotx_io::MassTransition { + precursor_mz: self.read_optional_f64()?, + product_mz: self.read_optional_f64()?, + collision_energy: self.read_optional_f64()?, + activation_method: self.read_optional_string()?, + }), + tag => return Err(invalid_tag("chromatogram transition presence", tag)), + }; let source_stream = self.read_optional_u64()?.map(AcquisitionStreamId::new); let coordinate = self.read_optional_f64()?; let description = self.read_string()?; @@ -593,6 +640,8 @@ impl<'a, 'p, R: Read> Reader<'a, 'p, R> { Ok(ChromatogramChannel { id, kind, + polarity, + transition, source_stream, coordinate, description, @@ -664,137 +713,5 @@ fn decode_bytes(bytes: &[u8]) -> Result { mod project_tests; #[cfg(test)] -mod tests { - use super::*; - - fn minimal_run_prefix() -> Vec { - let mut bytes = MAGIC.to_vec(); - bytes.extend_from_slice(&VERSION.to_le_bytes()); - bytes.extend_from_slice(&0_u64.to_le_bytes()); // source - bytes.push(0); // instrument - bytes.extend_from_slice(&0_u64.to_le_bytes()); // metadata - bytes - } - - fn assert_count_rejected_without_payload(bytes: &[u8], label: &str) { - let message = decode_bytes(bytes).unwrap_err().to_string(); - assert!( - message.contains(label) - && (message.contains("remaining") || message.contains("truncated")), - "{message}" - ); - } - - #[test] - fn rejects_unknown_future_version_precisely() { - let mut bytes = MAGIC.to_vec(); - bytes.extend_from_slice(&2_u16.to_le_bytes()); - let error = decode_bytes(&bytes).unwrap_err(); - assert!(error.to_string().contains("LC–MS payload version 2")); - } - - #[test] - fn rejects_truncated_header_invalid_tag_and_huge_length_before_allocation() { - assert!( - decode_bytes(MAGIC) - .unwrap_err() - .to_string() - .contains("truncated") - ); - - let mut invalid_tag = MAGIC.to_vec(); - invalid_tag.extend_from_slice(&VERSION.to_le_bytes()); - invalid_tag.extend_from_slice(&0_u64.to_le_bytes()); - invalid_tag.push(2); - assert!( - decode_bytes(&invalid_tag) - .unwrap_err() - .to_string() - .contains("invalid option tag 2") - ); - - let mut huge = MAGIC.to_vec(); - huge.extend_from_slice(&VERSION.to_le_bytes()); - huge.extend_from_slice(&u64::MAX.to_le_bytes()); - let message = decode_bytes(&huge).unwrap_err().to_string(); - assert!( - message.contains("string exceeds") || message.contains("length exceeds"), - "{message}" - ); - } - - #[test] - fn rejects_large_structural_counts_without_reserving_the_claimed_collection() { - const LARGE_COUNT: u64 = 10_000_000; - - let mut warnings = minimal_run_prefix(); - warnings.extend_from_slice(&LARGE_COUNT.to_le_bytes()); - assert_count_rejected_without_payload(&warnings, "warning count"); - - let mut streams = minimal_run_prefix(); - streams.extend_from_slice(&0_u64.to_le_bytes()); // warnings - streams.extend_from_slice(&LARGE_COUNT.to_le_bytes()); - assert_count_rejected_without_payload(&streams, "stream count"); - - let mut spectra = minimal_run_prefix(); - spectra.extend_from_slice(&0_u64.to_le_bytes()); // warnings - spectra.extend_from_slice(&1_u64.to_le_bytes()); // streams - spectra.extend_from_slice(&7_u64.to_le_bytes()); // stream id - spectra.push(0); // source native id - spectra.push(0); // source label - spectra.push(0); // primary role - spectra.push(0); // acquisition range - spectra.extend_from_slice(&LARGE_COUNT.to_le_bytes()); - assert_count_rejected_without_payload(&spectra, "spectrum count"); - } - - #[test] - fn payload_round_trips_streams_spectra_channels_and_precursors() { - let mut run = crate::state::sample_mass_spec_run(); - run.instrument = Some("QTOF".to_owned()); - run.streams[0].spectra[1].precursor = Some(Precursor { - selected_mz: 445.2, - charge: Some(2), - isolation_window_lower_offset: Some(0.5), - isolation_window_upper_offset: Some(0.75), - collision_energy: Some(20.0), - activation_method: Some("CID".to_owned()), - }); - let decoded = decode_bytes(&encode(&run).unwrap()).unwrap(); - assert_eq!(decoded.source, run.source); - assert_eq!(decoded.instrument, run.instrument); - assert_eq!(decoded.metadata, run.metadata); - assert_eq!(decoded.import_warnings, run.import_warnings); - assert_eq!(decoded.streams.len(), 3); - assert_eq!(decoded.streams[0].role, StreamRole::Primary); - assert_eq!(decoded.streams[0].source_label, run.streams[0].source_label); - assert_eq!(decoded.streams[0].spectra[1].id, SpectrumId::new(12)); - assert_eq!(decoded.streams[0].spectra[1].mz, [20.0, 30.0]); - let precursor = decoded.streams[0].spectra[1].precursor.as_ref().unwrap(); - assert_eq!(precursor.selected_mz, 445.2); - assert_eq!(precursor.charge, Some(2)); - assert_eq!(precursor.activation_method.as_deref(), Some("CID")); - assert_eq!(decoded.chromatograms.len(), 3); - assert_eq!(decoded.chromatograms[0].kind, run.chromatograms[0].kind); - assert_eq!(decoded.chromatograms[0].values, run.chromatograms[0].values); - } - - #[test] - fn rejects_truncated_and_trailing_payloads() { - let bytes = encode(&crate::state::sample_mass_spec_run()).unwrap(); - assert!( - decode_bytes(&bytes[..bytes.len() - 1]) - .unwrap_err() - .to_string() - .contains("truncated") - ); - let mut trailing = bytes; - trailing.push(0); - assert!( - decode_bytes(&trailing) - .unwrap_err() - .to_string() - .contains("trailing data") - ); - } -} +#[path = "mass_spec_convert_tests.rs"] +mod tests; diff --git a/crates/core/src/project/mass_spec_convert_tests.rs b/crates/core/src/project/mass_spec_convert_tests.rs new file mode 100644 index 0000000..0694a39 --- /dev/null +++ b/crates/core/src/project/mass_spec_convert_tests.rs @@ -0,0 +1,148 @@ +use super::*; + +fn minimal_run_prefix() -> Vec { + let mut bytes = MAGIC.to_vec(); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + bytes.extend_from_slice(&0_u64.to_le_bytes()); + bytes.push(0); + bytes.extend_from_slice(&0_u64.to_le_bytes()); + bytes +} + +fn assert_count_rejected_without_payload(bytes: &[u8], label: &str) { + let message = decode_bytes(bytes).unwrap_err().to_string(); + assert!( + message.contains(label) && (message.contains("remaining") || message.contains("truncated")), + "{message}" + ); +} + +#[test] +fn rejects_unknown_future_version_precisely() { + let mut bytes = MAGIC.to_vec(); + bytes.extend_from_slice(&2_u16.to_le_bytes()); + let error = decode_bytes(&bytes).unwrap_err(); + assert!(error.to_string().contains("LC–MS payload version 2")); +} + +#[test] +fn rejects_truncated_header_invalid_tag_and_huge_length_before_allocation() { + assert!( + decode_bytes(MAGIC) + .unwrap_err() + .to_string() + .contains("truncated") + ); + + let mut invalid_tag = MAGIC.to_vec(); + invalid_tag.extend_from_slice(&VERSION.to_le_bytes()); + invalid_tag.extend_from_slice(&0_u64.to_le_bytes()); + invalid_tag.push(2); + assert!( + decode_bytes(&invalid_tag) + .unwrap_err() + .to_string() + .contains("invalid option tag 2") + ); + + let mut huge = MAGIC.to_vec(); + huge.extend_from_slice(&VERSION.to_le_bytes()); + huge.extend_from_slice(&u64::MAX.to_le_bytes()); + let message = decode_bytes(&huge).unwrap_err().to_string(); + assert!( + message.contains("string exceeds") || message.contains("length exceeds"), + "{message}" + ); +} + +#[test] +fn rejects_large_structural_counts_without_reserving_the_claimed_collection() { + const LARGE_COUNT: u64 = 10_000_000; + let mut warnings = minimal_run_prefix(); + warnings.extend_from_slice(&LARGE_COUNT.to_le_bytes()); + assert_count_rejected_without_payload(&warnings, "warning count"); + + let mut streams = minimal_run_prefix(); + streams.extend_from_slice(&0_u64.to_le_bytes()); + streams.extend_from_slice(&LARGE_COUNT.to_le_bytes()); + assert_count_rejected_without_payload(&streams, "stream count"); + + let mut spectra = minimal_run_prefix(); + spectra.extend_from_slice(&0_u64.to_le_bytes()); + spectra.extend_from_slice(&1_u64.to_le_bytes()); + spectra.extend_from_slice(&7_u64.to_le_bytes()); + spectra.extend_from_slice(&[0, 0, 0, 0]); + spectra.extend_from_slice(&LARGE_COUNT.to_le_bytes()); + assert_count_rejected_without_payload(&spectra, "spectrum count"); +} + +#[test] +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].precursor = Some(Precursor { + selected_mz: 445.2, + charge: Some(2), + isolation_window_lower_offset: Some(0.5), + isolation_window_upper_offset: Some(0.75), + collision_energy: Some(20.0), + activation_method: Some("CID".to_owned()), + }); + run.chromatograms[0].kind = ChromatogramKind::SelectedReactionMonitoring; + run.chromatograms[0].polarity = Polarity::Positive; + run.chromatograms[0].transition = Some(plotx_io::MassTransition { + precursor_mz: Some(445.2), + product_mz: Some(220.1), + collision_energy: Some(20.0), + activation_method: Some("CID".to_owned()), + }); + let decoded = decode_bytes(&encode(&run).unwrap()).unwrap(); + assert_eq!(decoded.source, run.source); + assert_eq!(decoded.instrument, run.instrument); + assert_eq!(decoded.metadata, run.metadata); + assert_eq!(decoded.import_warnings, run.import_warnings); + assert_eq!(decoded.streams.len(), 3); + 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]); + let precursor = decoded.streams[0].spectra[1].precursor.as_ref().unwrap(); + assert_eq!(precursor.selected_mz, 445.2); + assert_eq!(precursor.charge, Some(2)); + assert_eq!(precursor.activation_method.as_deref(), Some("CID")); + let channel = &decoded.chromatograms[0]; + assert_eq!(channel.kind, ChromatogramKind::SelectedReactionMonitoring); + assert_eq!(channel.polarity, Polarity::Positive); + let transition = channel.transition.as_ref().unwrap(); + assert_eq!(transition.precursor_mz, Some(445.2)); + assert_eq!(transition.product_mz, Some(220.1)); + assert_eq!(transition.activation_method.as_deref(), Some("CID")); +} + +#[test] +fn rejects_truncated_and_trailing_payloads() { + let bytes = encode(&crate::state::sample_mass_spec_run()).unwrap(); + assert!( + decode_bytes(&bytes[..bytes.len() - 1]) + .unwrap_err() + .to_string() + .contains("truncated") + ); + let mut trailing = bytes; + trailing.push(0); + assert!( + decode_bytes(&trailing) + .unwrap_err() + .to_string() + .contains("trailing data") + ); +} + +#[test] +fn chromatogram_only_payload_round_trips_with_no_active_stream() { + let mut run = crate::state::sample_mass_spec_run(); + run.streams.clear(); + run.chromatograms.truncate(1); + let decoded = decode_bytes(&encode(&run).unwrap()).unwrap(); + assert!(decoded.streams.is_empty()); + assert_eq!(decoded.chromatograms.len(), 1); +} diff --git a/crates/core/src/state/charts.rs b/crates/core/src/state/charts.rs index 965ee28..83130c5 100644 --- a/crates/core/src/state/charts.rs +++ b/crates/core/src/state/charts.rs @@ -432,7 +432,14 @@ fn build_mass_chromatogram(dataset: &Dataset, _ctx: &ChartContext) -> Option Option { + if let Self::MassSpec(dataset) = self { + return field_mass_spec::default_field_id(dataset); + } if let Self::Nmr2D(dataset) = self && !dataset.is_true_2d() { @@ -469,10 +473,16 @@ impl super::Dataset { } pub fn has_field(&self, id: FieldId) -> bool { + if let Self::MassSpec(dataset) = self { + return dataset.field_catalog.key_for_id(id).is_some(); + } self.field_descriptors().iter().any(|field| field.id == id) } pub fn field_descriptor(&self, id: FieldId) -> Option { + if let Self::MassSpec(dataset) = self { + return field_mass_spec::descriptor(dataset, id); + } self.field_descriptors() .into_iter() .find(|field| field.id == id) diff --git a/crates/core/src/state/field_catalog.rs b/crates/core/src/state/field_catalog.rs index bd53597..3e20aa6 100644 --- a/crates/core/src/state/field_catalog.rs +++ b/crates/core/src/state/field_catalog.rs @@ -44,6 +44,12 @@ impl FieldCatalog { self.key_to_id.get(key).copied() } + pub(crate) fn key_for_id(&self, id: FieldId) -> Option<&str> { + self.key_to_id + .iter() + .find_map(|(key, candidate)| (*candidate == id).then_some(key.as_str())) + } + pub fn trace_collection(&self, field: FieldId) -> Option<&TraceCollectionCatalog> { self.trace_collections.get(&field) } diff --git a/crates/core/src/state/field_mass_spec.rs b/crates/core/src/state/field_mass_spec.rs new file mode 100644 index 0000000..76f640c --- /dev/null +++ b/crates/core/src/state/field_mass_spec.rs @@ -0,0 +1,153 @@ +use super::*; +use crate::automation::{CAP_FIELD_MASS_CHROMATOGRAM, CAP_FIELD_MASS_SPECTRUM, CapabilityId}; +use crate::state::{FieldMetadata, MassSpecDataset, readable_ms_stream}; + +pub(super) fn default_field_id(dataset: &MassSpecDataset) -> Option { + dataset + .run + .streams + .iter() + .find(|stream| readable_ms_stream(stream)) + .and_then(|stream| dataset.field_catalog.id_for_key(&stream_tic_key(stream.id))) + .or_else(|| { + dataset + .run + .chromatograms + .iter() + .find(|channel| channel.source_stream.is_none() && channel.kind.is_signal()) + .and_then(|channel| { + dataset + .field_catalog + .id_for_key(&channel_key(&channel.id.0)) + }) + }) +} + +pub(super) fn descriptor(dataset: &MassSpecDataset, id: FieldId) -> Option { + let key = dataset.field_catalog.key_for_id(id)?; + if let Some(channel_id) = key.strip_prefix("mass_spec.channel.") { + let channel = dataset + .run + .chromatograms + .iter() + .find(|channel| channel.id.0 == channel_id)?; + return Some(build( + dataset, + id, + key, + &channel.description, + CAP_FIELD_MASS_CHROMATOGRAM, + channel.values.len(), + vec!["min".to_owned(), channel.unit.clone()], + )); + } + for stream in dataset + .run + .streams + .iter() + .filter(|stream| readable_ms_stream(stream)) + { + let stream_label = stream_display_label(stream); + if key == stream_tic_key(stream.id) { + return Some(build( + dataset, + id, + key, + &format!("{stream_label} TIC"), + CAP_FIELD_MASS_CHROMATOGRAM, + stream.spectra.len(), + vec!["min".to_owned()], + )); + } + if key == stream_bpi_key(stream.id) { + return Some(build( + dataset, + id, + key, + &format!("{stream_label} BPI"), + CAP_FIELD_MASS_CHROMATOGRAM, + stream.spectra.len(), + vec!["min".to_owned()], + )); + } + if key == stream_spectrum_key(stream.id) { + let length = stream + .spectra + .iter() + .map(|scan| scan.mz.len()) + .max() + .unwrap_or(0); + return Some(build( + dataset, + id, + key, + &format!("{stream_label} current spectrum"), + CAP_FIELD_MASS_SPECTRUM, + length, + vec!["m/z".to_owned()], + )); + } + } + if let Some(extraction) = dataset + .extracted_spectra + .iter() + .find(|item| key == extracted_stream_spectrum_key(item.id)) + { + return Some(build( + dataset, + id, + key, + &extraction_title(&dataset.run, extraction), + CAP_FIELD_MASS_SPECTRUM, + 0, + vec!["m/z".to_owned()], + )); + } + let xic = dataset + .extracted_ion_chromatograms + .iter() + .find(|item| key == xic_key(item.id))?; + Some(build( + dataset, + id, + key, + &xic_title(&dataset.run, xic), + CAP_FIELD_MASS_CHROMATOGRAM, + xic.intensity.len(), + vec!["min".to_owned()], + )) +} + +fn build( + dataset: &MassSpecDataset, + id: FieldId, + key: &str, + name: &str, + capability: &str, + length: usize, + units: Vec, +) -> FieldDescriptor { + let x_unit = units.first().cloned().unwrap_or_default(); + let intrinsic = dataset + .field_representation(id) + .map(FieldRepresentation::intrinsic_capabilities) + .unwrap_or_default(); + FieldDescriptor { + id, + local_id: key.to_owned(), + name: name.to_owned(), + scientific_observation: SummaryPart::new(format!("field:{key}"), name), + capabilities: FieldCapabilities::new( + intrinsic + .iter() + .cloned() + .chain(std::iter::once(CapabilityId::new(capability))), + ), + dimensions: vec![length], + units, + metadata: FieldMetadata(BTreeMap::from([ + ("recommended_encoding".to_owned(), "line".to_owned()), + (LINE_X_UNIT_METADATA_KEY.to_owned(), x_unit), + ])), + } +} diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index 38f9f7f..5e55bdb 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -122,8 +122,7 @@ impl MassSpecDataset { pub fn load(run: MassSpecRun) -> Self { let acquisition_identity = plotx_io::AcquisitionIdentity::from_path(std::path::Path::new(&run.source)); - let active_stream = - first_ms_stream(&run).expect("a validated LC–MS run has a readable primary stream"); + let active_stream = first_ms_stream(&run).unwrap_or(AcquisitionStreamId::new(0)); let mut field_catalog = mass_spec_field_catalog(&run); field_catalog.attach_provenance(&run.source, None); Self { @@ -148,8 +147,8 @@ impl MassSpecDataset { .stream(self.active_stream) .is_some_and(readable_ms_stream); if !active_valid { - self.active_stream = first_ms_stream(&self.run) - .ok_or_else(|| "LC–MS run has no readable non-reference MS stream".to_owned())?; + self.active_stream = first_ms_stream(&self.run).unwrap_or(AcquisitionStreamId::new(0)); + self.selected_spectrum = None; } if self.selected_spectrum.is_some_and(|selected| { self.run @@ -219,42 +218,13 @@ impl MassSpecDataset { } pub(crate) fn field_representation(&self, id: FieldId) -> Option { - for stream in self - .run - .streams - .iter() - .filter(|stream| readable_ms_stream(stream)) - { - if self.field_catalog.id_for_key(&stream_tic_key(stream.id)) == Some(id) - || self.field_catalog.id_for_key(&stream_bpi_key(stream.id)) == Some(id) - { - return Some(super::FieldRepresentation::Curve1D); - } - if self - .field_catalog - .id_for_key(&stream_spectrum_key(stream.id)) - == Some(id) - { - return (stream.id == self.active_stream && self.selected_spectrum().is_some()) - .then_some(super::FieldRepresentation::Curve1D); - } - } - if self.extracted_spectra.iter().any(|extraction| { - self.field_catalog - .id_for_key(&extracted_stream_spectrum_key(extraction.id)) - == Some(id) - }) || self - .extracted_ion_chromatograms - .iter() - .any(|xic| self.field_catalog.id_for_key(&xic_key(xic.id)) == Some(id)) - || self.run.chromatograms.iter().any(|channel| { - self.field_catalog.id_for_key(&channel_key(&channel.id.0)) == Some(id) - }) - { - Some(super::FieldRepresentation::Curve1D) - } else { - None + let key = self.field_catalog.key_for_id(id)?; + if key.starts_with("mass_spec.stream.") && key.ends_with(".spectrum") { + return (key == stream_spectrum_key(self.active_stream) + && self.selected_spectrum().is_some()) + .then_some(super::FieldRepresentation::Curve1D); } + Some(super::FieldRepresentation::Curve1D) } pub fn add_extraction( @@ -342,6 +312,12 @@ impl MassSpecDataset { } pub fn tic_panel_note(&self) -> String { + if self.run.stream(self.active_stream).is_none() { + return self.run.chromatograms.first().map_or_else( + || "Mass chromatogram".to_owned(), + |channel| channel.description.clone(), + ); + } let polarity = self .run .stream(self.active_stream) @@ -587,7 +563,7 @@ pub(crate) fn mass_spec_field_keys(run: &MassSpecRun) -> Vec { .chain( run.chromatograms .iter() - .filter(|channel| channel.kind == ChromatogramKind::Optical) + .filter(|channel| channel.source_stream.is_none() && channel.kind.is_signal()) .map(|channel| channel_key(&channel.id.0)), ) .collect() @@ -699,98 +675,10 @@ fn extracted_points( } #[cfg(test)] -pub(crate) fn sample_mass_spec_run() -> MassSpecRun { - use plotx_io::{ - AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, Polarity, - SpectrumRepresentation, - }; - let scan = |id, time, tic, polarity, mz: &[f64], intensity: &[f64]| MassSpectrum { - id: SpectrumId::new(id), - source_native_id: Some(id.to_string()), - retention_time_min: time, - ms_level: 1, - polarity, - representation: SpectrumRepresentation::Profile, - mz: mz.to_vec(), - intensity: intensity.to_vec(), - tic, - base_peak_mz: mz.first().copied(), - base_peak_intensity: intensity.first().copied(), - precursor: None, - }; - MassSpecRun { - source: "synthetic.raw".to_owned(), - metadata: [("Sample".to_owned(), "test".to_owned())] - .into_iter() - .collect(), - instrument: Some("SQD2".to_owned()), - streams: vec![ - AcquisitionStream { - id: AcquisitionStreamId::new(3), - source_native_id: Some("3".to_owned()), - source_label: Some("Function 3".to_owned()), - role: StreamRole::Primary, - acquisition_range: Some([10.0, 500.0]), - spectra: vec![ - scan(11, 0.5, 2.0, Polarity::Positive, &[10.0], &[2.0]), - scan(12, 1.0, 9.0, Polarity::Positive, &[20.0, 30.0], &[9.0, 1.0]), - ], - }, - AcquisitionStream { - id: AcquisitionStreamId::new(5), - source_native_id: Some("5".to_owned()), - source_label: Some("Function 5".to_owned()), - role: StreamRole::Reference, - acquisition_range: None, - spectra: vec![], - }, - AcquisitionStream { - id: AcquisitionStreamId::new(7), - source_native_id: Some("7".to_owned()), - source_label: Some("Function 7".to_owned()), - role: StreamRole::Primary, - acquisition_range: Some([20.0, 800.0]), - spectra: vec![ - scan(101, 0.4, 4.0, Polarity::Negative, &[40.0], &[4.0]), - scan(105, 1.4, 3.0, Polarity::Negative, &[50.0], &[3.0]), - ], - }, - ], - chromatograms: vec![ - ChromatogramChannel { - id: ChromatogramChannelId("stream:9:coordinate:217.5".to_owned()), - kind: ChromatogramKind::Optical, - source_stream: None, - coordinate: Some(217.5), - description: "PDA 217.5 nm".to_owned(), - unit: "AU".to_owned(), - time_min: vec![0.5, 1.0], - values: vec![-1.0, 2.0], - }, - ChromatogramChannel { - id: ChromatogramChannelId("stream:9:coordinate:280".to_owned()), - kind: ChromatogramKind::Optical, - source_stream: None, - coordinate: Some(280.0), - description: "PDA 280 nm".to_owned(), - unit: "AU".to_owned(), - time_min: vec![0.5, 1.0], - values: vec![3.0, 4.0], - }, - ChromatogramChannel { - id: ChromatogramChannelId("auxiliary:1".to_owned()), - kind: ChromatogramKind::Temperature, - source_stream: None, - coordinate: None, - description: "Sample temperature".to_owned(), - unit: "°C".to_owned(), - time_min: vec![0.5], - values: vec![25.0], - }, - ], - import_warnings: vec!["optional reference was unavailable".to_owned()], - } -} +#[path = "mass_spec_fixture.rs"] +mod fixture; +#[cfg(test)] +pub(crate) use fixture::sample_mass_spec_run; #[cfg(test)] #[path = "mass_spec_interaction_tests.rs"] diff --git a/crates/core/src/state/mass_spec_fixture.rs b/crates/core/src/state/mass_spec_fixture.rs new file mode 100644 index 0000000..91cc948 --- /dev/null +++ b/crates/core/src/state/mass_spec_fixture.rs @@ -0,0 +1,113 @@ +use super::*; +use plotx_io::{ + AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, Polarity, SpectrumRepresentation, +}; + +pub(crate) fn sample_mass_spec_run() -> MassSpecRun { + let scan = |id, time, tic, polarity, mz: &[f64], intensity: &[f64]| MassSpectrum { + id: SpectrumId::new(id), + source_native_id: Some(id.to_string()), + retention_time_min: time, + ms_level: 1, + polarity, + representation: SpectrumRepresentation::Profile, + mz: mz.to_vec(), + intensity: intensity.to_vec(), + tic, + base_peak_mz: mz.first().copied(), + base_peak_intensity: intensity.first().copied(), + precursor: None, + }; + MassSpecRun { + source: "synthetic.raw".to_owned(), + metadata: [("Sample".to_owned(), "test".to_owned())] + .into_iter() + .collect(), + instrument: Some("SQD2".to_owned()), + streams: vec![ + AcquisitionStream { + id: AcquisitionStreamId::new(3), + source_native_id: Some("3".to_owned()), + source_label: Some("Function 3".to_owned()), + role: StreamRole::Primary, + acquisition_range: Some([10.0, 500.0]), + spectra: vec![ + scan(11, 0.5, 2.0, Polarity::Positive, &[10.0], &[2.0]), + scan(12, 1.0, 9.0, Polarity::Positive, &[20.0, 30.0], &[9.0, 1.0]), + ], + }, + AcquisitionStream { + id: AcquisitionStreamId::new(5), + source_native_id: Some("5".to_owned()), + source_label: Some("Function 5".to_owned()), + role: StreamRole::Reference, + acquisition_range: None, + spectra: vec![], + }, + AcquisitionStream { + id: AcquisitionStreamId::new(7), + source_native_id: Some("7".to_owned()), + source_label: Some("Function 7".to_owned()), + role: StreamRole::Primary, + acquisition_range: Some([20.0, 800.0]), + spectra: vec![ + scan(101, 0.4, 4.0, Polarity::Negative, &[40.0], &[4.0]), + scan(105, 1.4, 3.0, Polarity::Negative, &[50.0], &[3.0]), + ], + }, + ], + chromatograms: vec![ + channel( + "stream:9:coordinate:217.5", + ChromatogramKind::Optical, + Some(217.5), + "PDA 217.5 nm", + "AU", + &[0.5, 1.0], + &[-1.0, 2.0], + ), + channel( + "stream:9:coordinate:280", + ChromatogramKind::Optical, + Some(280.0), + "PDA 280 nm", + "AU", + &[0.5, 1.0], + &[3.0, 4.0], + ), + channel( + "auxiliary:1", + ChromatogramKind::Temperature, + None, + "Sample temperature", + "°C", + &[0.5], + &[25.0], + ), + ], + import_warnings: vec!["optional reference was unavailable".to_owned()], + } +} + +fn channel( + id: &str, + kind: ChromatogramKind, + coordinate: Option, + description: &str, + unit: &str, + time_min: &[f64], + values: &[f64], +) -> ChromatogramChannel { + ChromatogramChannel { + id: ChromatogramChannelId(id.to_owned()), + kind, + polarity: Polarity::Unknown, + transition: None, + source_stream: None, + coordinate, + description: description.to_owned(), + unit: unit.to_owned(), + time_min: time_min.to_vec(), + values: values.to_vec(), + } +} diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index a2e2bf7..d3eb259 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -71,6 +71,29 @@ fn default_lcms_canvas_without_optical_data_contains_only_tic() { assert!(canvas.objects[0].name.starts_with("Total ion chromatogram")); } +#[test] +fn chromatogram_only_run_uses_existing_fields_and_default_canvas() { + let mut run = sample_mass_spec_run(); + run.streams.clear(); + run.chromatograms.truncate(1); + run.chromatograms[0].kind = ChromatogramKind::SelectedReactionMonitoring; + run.chromatograms[0].description = "SRM 455.2 -> 520.2".to_owned(); + assert!(run.validate().is_ok()); + + let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(run))); + let mass_spec = dataset.as_mass_spec().unwrap(); + assert_eq!(mass_spec.active_stream, AcquisitionStreamId::new(0)); + assert!(mass_spec.supported_ms_streams().next().is_none()); + assert_eq!(mass_spec_field_keys(&mass_spec.run).len(), 1); + + let canvas = crate::workflow::build_default_canvas(&dataset, "mrm.mzML"); + assert_eq!(canvas.objects.len(), 1); + assert_eq!(canvas.objects[0].name, "SRM 455.2 -> 520.2"); + let plot = canvas.objects[0].plot().unwrap(); + assert_eq!(plot.chart.type_id, "mass_chromatogram"); + assert_eq!(plot.figure().series[0].points, [[0.5, -1.0], [1.0, 2.0]]); +} + #[test] fn mean_extraction_averages_missing_profile_coordinates_as_zero() { let mut dataset = MassSpecDataset::load(sample_mass_spec_run()); @@ -92,7 +115,9 @@ 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, + kind: ChromatogramKind::TotalIonCurrent, + polarity: plotx_io::Polarity::Unknown, + transition: None, source_stream: Some(AcquisitionStreamId::new(3)), coordinate: None, description: "Total ion current".to_owned(), @@ -115,7 +140,9 @@ 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, + kind: ChromatogramKind::TotalIonCurrent, + polarity: plotx_io::Polarity::Unknown, + transition: None, source_stream: Some(AcquisitionStreamId::new(3)), coordinate: None, description: "Total ion current".to_owned(), diff --git a/crates/core/src/state/mass_spec_tic.rs b/crates/core/src/state/mass_spec_tic.rs index 418104e..61fe6fc 100644 --- a/crates/core/src/state/mass_spec_tic.rs +++ b/crates/core/src/state/mass_spec_tic.rs @@ -4,10 +4,13 @@ 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))?; + let channel = run.chromatograms.iter().find(|channel| { + channel.source_stream == Some(stream_id) + && matches!( + channel.kind, + plotx_io::ChromatogramKind::TotalIonCurrent | plotx_io::ChromatogramKind::Unknown + ) + })?; if channel.time_min.len() != channel.values.len() { return None; } diff --git a/crates/io/src/mass_spec.rs b/crates/io/src/mass_spec.rs index 3ec6507..57c5f98 100644 --- a/crates/io/src/mass_spec.rs +++ b/crates/io/src/mass_spec.rs @@ -122,6 +122,10 @@ impl AcquisitionStream { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ChromatogramKind { + TotalIonCurrent, + BasePeak, + SelectedIonMonitoring, + SelectedReactionMonitoring, Optical, Temperature, Pressure, @@ -129,10 +133,29 @@ pub enum ChromatogramKind { Unknown, } +impl ChromatogramKind { + pub fn is_signal(self) -> bool { + !matches!( + self, + Self::Temperature | Self::Pressure | Self::Housekeeping + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MassTransition { + pub precursor_mz: Option, + pub product_mz: Option, + pub collision_energy: Option, + pub activation_method: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChromatogramChannel { pub id: ChromatogramChannelId, pub kind: ChromatogramKind, + pub polarity: Polarity, + pub transition: Option, pub source_stream: Option, pub coordinate: Option, pub description: String, @@ -262,9 +285,13 @@ impl MassSpecRun { } } if !self - .streams + .chromatograms .iter() - .any(|stream| stream.role == StreamRole::Primary && !stream.spectra.is_empty()) + .any(|channel| channel.kind.is_signal()) + && !self + .streams + .iter() + .any(|stream| stream.role == StreamRole::Primary && !stream.spectra.is_empty()) { return Err("run has no readable non-reference MS stream".to_owned()); } @@ -294,6 +321,19 @@ impl MassSpecRun { if channel.coordinate.is_some_and(|value| !value.is_finite()) { return Err(format!("channel {} has an invalid coordinate", channel.id)); } + if let Some(transition) = &channel.transition + && (transition + .precursor_mz + .is_some_and(|value| !value.is_finite() || value <= 0.0) + || transition + .product_mz + .is_some_and(|value| !value.is_finite() || value <= 0.0) + || transition + .collision_energy + .is_some_and(|value| !value.is_finite() || value < 0.0)) + { + return Err(format!("channel {} has an invalid transition", channel.id)); + } } Ok(()) } @@ -438,6 +478,8 @@ mod tests { let channel = ChromatogramChannel { id: ChromatogramChannelId("tic".to_owned()), kind: ChromatogramKind::Unknown, + polarity: Polarity::Unknown, + transition: None, source_stream: None, coordinate: None, description: "TIC".to_owned(), diff --git a/crates/io/src/mzml.rs b/crates/io/src/mzml.rs index a241d82..6ef1ade 100644 --- a/crates/io/src/mzml.rs +++ b/crates/io/src/mzml.rs @@ -22,6 +22,9 @@ use std::{ path::Path, }; +#[path = "mzml_chromatogram.rs"] +mod chromatogram; + const MAX_SPECTRA: usize = 1_000_000; const MAX_POINTS_PER_SPECTRUM: usize = 5_000_000; const MAX_DECODED_BYTES_PER_ARRAY: usize = 40_000_000; @@ -37,7 +40,7 @@ const RETAINED_XML_BUFFER_BYTES: usize = 64 * 1024; /// found. This wrapper stops exposing source bytes after one event's budget; /// the parser therefore receives an I/O error before it can append beyond the /// configured limit, including for unterminated tokens. -struct EventBounded { +pub(super) struct EventBounded { inner: R, remaining: usize, } @@ -88,22 +91,24 @@ impl BufRead for EventBounded { struct StreamKey(u8, u8); #[derive(Default)] -struct BinaryArray { - kind: Option, - precision: Option, - zlib: bool, - unsupported: Option, - text: Vec, +pub(super) struct BinaryArray { + pub(super) kind: Option, + pub(super) precision: Option, + pub(super) zlib: bool, + pub(super) unsupported: Option, + pub(super) optional_auxiliary: bool, + pub(super) text: Vec, } #[derive(Clone, Copy)] -enum ArrayKind { +pub(super) enum ArrayKind { Mz, Intensity, + Time, } #[derive(Clone, Copy)] -enum Precision { +pub(super) enum Precision { F32, F64, } @@ -151,6 +156,7 @@ pub fn parse(input: impl BufRead, source: String) -> Result Result { + let tag = tag.into_owned(); + let channel = chromatogram::parse( + &mut reader, + &mut buffer, + &tag, + chromatograms.len(), + &mut warnings, + &mut total_decoded, + )?; + chromatograms.push(channel); + if buffer.capacity() > RETAINED_XML_BUFFER_BYTES { + buffer = Vec::with_capacity(RETAINED_XML_BUFFER_BYTES); + } + } Event::Eof => break, _ => {} } @@ -192,8 +213,8 @@ pub fn parse(input: impl BufRead, source: String) -> Result> = BTreeMap::new(); @@ -231,7 +252,7 @@ pub fn parse(input: impl BufRead, source: String) -> Result( let target = match kind { ArrayKind::Mz => &mut draft.mz, ArrayKind::Intensity => &mut draft.intensity, + ArrayKind::Time => { + return Err(invalid(format!( + "spectrum {label} contains an unexpected time array" + ))); + } }; if target.replace(values).is_some() { return Err(invalid(format!( @@ -432,7 +458,7 @@ fn apply_cv( Ok(()) } -fn read_binary_text( +pub(super) fn read_binary_text( reader: &mut Reader>, buffer: &mut Vec, output: &mut Vec, @@ -469,7 +495,7 @@ fn read_binary_text( } } -fn decode_array( +pub(super) fn decode_array( array: BinaryArray, declared: Option, label: &str, @@ -558,7 +584,7 @@ fn decode_array( Ok((kind, values)) } -fn attribute(tag: &BytesStart<'_>, name: &[u8]) -> Result, IoError> { +pub(super) fn attribute(tag: &BytesStart<'_>, name: &[u8]) -> Result, IoError> { for attribute in tag.attributes() { let attribute = attribute.map_err(|error| invalid(format!("invalid XML attribute: {error}")))?; @@ -577,7 +603,7 @@ fn attribute(tag: &BytesStart<'_>, name: &[u8]) -> Result, IoErro Ok(None) } -fn read_event<'buffer, R: BufRead>( +pub(super) fn read_event<'buffer, R: BufRead>( reader: &mut Reader>, buffer: &'buffer mut Vec, limit: usize, @@ -663,7 +689,7 @@ fn polarity_label(p: Polarity) -> &'static str { Polarity::Unknown => "unknown polarity", } } -fn push_warning(warnings: &mut Vec, message: String) { +pub(super) fn push_warning(warnings: &mut Vec, message: String) { if warnings.len() + 1 < MAX_IMPORT_WARNINGS { warnings.push(message); } else if warnings.len() + 1 == MAX_IMPORT_WARNINGS { @@ -673,7 +699,7 @@ fn push_warning(warnings: &mut Vec, message: String) { fn xml_error(error: quick_xml::Error) -> IoError { invalid(format!("XML parsing failed: {error}")) } -fn invalid(message: impl Into) -> IoError { +pub(super) fn invalid(message: impl Into) -> IoError { IoError::InvalidMzMl(message.into()) } diff --git a/crates/io/src/mzml_chromatogram.rs b/crates/io/src/mzml_chromatogram.rs new file mode 100644 index 0000000..f29fdfa --- /dev/null +++ b/crates/io/src/mzml_chromatogram.rs @@ -0,0 +1,316 @@ +use super::{ + ArrayKind, BinaryArray, EventBounded, Precision, attribute, decode_array, invalid, + push_warning, read_binary_text, read_event, +}; +use crate::{ + ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, IoError, MassTransition, Polarity, +}; +use quick_xml::{ + Reader, + events::{BytesStart, Event}, +}; +use std::io::BufRead; + +const MAX_CHROMATOGRAMS: usize = 100_000; +const MAX_POINTS_PER_CHROMATOGRAM: usize = 5_000_000; +const MAX_XML_EVENT_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Copy, Default)] +enum Section { + #[default] + Other, + Precursor, + PrecursorActivation, + Product, +} + +struct Draft { + native_id: String, + declared_len: Option, + title: Option, + kind: ChromatogramKind, + polarity: Polarity, + precursor_mz: Option, + product_mz: Option, + collision_energy: Option, + activation_method: Option, + time_min: Option>, + time_unit: Option, + values: Option>, + unit: Option, +} + +pub(super) fn parse( + reader: &mut Reader>, + buffer: &mut Vec, + tag: &BytesStart<'_>, + ordinal: usize, + warnings: &mut Vec, + total_decoded: &mut usize, +) -> Result { + if ordinal >= MAX_CHROMATOGRAMS { + return Err(invalid(format!( + "chromatogram count exceeds limit {MAX_CHROMATOGRAMS}" + ))); + } + let native_id = attribute(tag, b"id")?.unwrap_or_else(|| format!("chromatogram={ordinal}")); + let declared_len = attribute(tag, b"defaultArrayLength")? + .map(|value| { + value.parse::().map_err(|_| { + invalid(format!( + "chromatogram {native_id} has invalid defaultArrayLength" + )) + }) + }) + .transpose()?; + if declared_len.is_some_and(|len| len > MAX_POINTS_PER_CHROMATOGRAM) { + return Err(invalid(format!( + "chromatogram {native_id} declares more than {MAX_POINTS_PER_CHROMATOGRAM} points" + ))); + } + let mut draft = Draft { + native_id, + declared_len, + title: None, + kind: ChromatogramKind::Unknown, + polarity: Polarity::Unknown, + precursor_mz: None, + product_mz: None, + collision_energy: None, + activation_method: None, + time_min: None, + time_unit: None, + values: None, + unit: None, + }; + let mut binary = None; + let mut section = Section::Other; + loop { + match read_event(reader, buffer, MAX_XML_EVENT_BYTES)? { + Event::Start(tag) if tag.local_name().as_ref() == b"precursor" => { + section = Section::Precursor; + } + Event::Start(tag) if tag.local_name().as_ref() == b"product" => { + section = Section::Product; + } + Event::Start(tag) if tag.local_name().as_ref() == b"activation" => { + section = Section::PrecursorActivation; + } + Event::End(tag) if tag.local_name().as_ref() == b"activation" => { + section = Section::Precursor; + } + Event::End(tag) if matches!(tag.local_name().as_ref(), b"precursor" | b"product") => { + section = Section::Other; + } + Event::Start(tag) if tag.local_name().as_ref() == b"binaryDataArray" => { + binary = Some(BinaryArray::default()); + } + Event::Empty(tag) | Event::Start(tag) if tag.local_name().as_ref() == b"cvParam" => { + apply_cv(&tag, &mut draft, binary.as_mut(), section)?; + } + Event::Start(tag) if tag.local_name().as_ref() == b"binary" => { + let array = binary.as_mut().ok_or_else(|| { + invalid(format!( + "chromatogram {} has binary outside binaryDataArray", + draft.native_id + )) + })?; + read_binary_text(reader, buffer, &mut array.text, &draft.native_id)?; + } + Event::End(tag) if tag.local_name().as_ref() == b"binaryDataArray" => { + let array = binary.take().ok_or_else(|| { + invalid(format!( + "chromatogram {} closes an unopened binaryDataArray", + draft.native_id + )) + })?; + finish_array(array, &mut draft, warnings, total_decoded)?; + } + Event::End(tag) if tag.local_name().as_ref() == b"chromatogram" => break, + Event::Eof => { + return Err(invalid(format!( + "input truncated inside chromatogram {}", + draft.native_id + ))); + } + _ => {} + } + buffer.clear(); + } + finish(draft) +} + +fn apply_cv( + tag: &BytesStart<'_>, + draft: &mut Draft, + binary: Option<&mut BinaryArray>, + section: Section, +) -> Result<(), IoError> { + let accession = attribute(tag, b"accession")?.unwrap_or_default(); + if let Some(array) = binary { + match accession.as_str() { + "MS:1000595" => array.kind = Some(ArrayKind::Time), + "MS:1000515" => array.kind = Some(ArrayKind::Intensity), + "MS:1000521" => array.precision = Some(Precision::F32), + "MS:1000523" => array.precision = Some(Precision::F64), + "MS:1000574" => array.zlib = true, + "MS:1000576" => {} + "MS:1002312" | "MS:1002313" | "MS:1002314" => { + array.unsupported = Some("MS-Numpress encoding".to_owned()); + } + "MS:1000140" => array.unsupported = Some("big-endian binary data".to_owned()), + "MS:1000786" => array.optional_auxiliary = true, + _ => {} + } + if matches!(array.kind, Some(ArrayKind::Time)) { + draft.time_unit = attribute(tag, b"unitAccession")?.or_else(|| draft.time_unit.take()); + } else if matches!(array.kind, Some(ArrayKind::Intensity)) { + draft.unit = attribute(tag, b"unitName")?.or_else(|| draft.unit.take()); + } + return Ok(()); + } + match accession.as_str() { + "MS:1000235" => { + draft.kind = ChromatogramKind::TotalIonCurrent; + draft.title = attribute(tag, b"name")?; + } + "MS:1000628" => { + draft.kind = ChromatogramKind::BasePeak; + draft.title = attribute(tag, b"name")?; + } + "MS:1001472" => draft.kind = ChromatogramKind::SelectedIonMonitoring, + "MS:1001473" => draft.kind = ChromatogramKind::SelectedReactionMonitoring, + "MS:1000130" => draft.polarity = Polarity::Positive, + "MS:1000129" => draft.polarity = Polarity::Negative, + "MS:1000827" if matches!(section, Section::Precursor | Section::Product) => { + let value = attribute(tag, b"value")? + .ok_or_else(|| invalid("transition target m/z has no value"))? + .parse::() + .map_err(|_| invalid("invalid transition target m/z"))?; + match section { + Section::Precursor => draft.precursor_mz = Some(value), + Section::Product => draft.product_mz = Some(value), + _ => {} + } + } + "MS:1000045" if matches!(section, Section::PrecursorActivation) => { + draft.collision_energy = attribute(tag, b"value")? + .map(|value| { + value + .parse::() + .map_err(|_| invalid("invalid collision energy")) + }) + .transpose()?; + } + _ if matches!(section, Section::PrecursorActivation) + && draft.activation_method.is_none() => + { + draft.activation_method = attribute(tag, b"name")?; + } + _ => {} + } + Ok(()) +} + +fn finish_array( + array: BinaryArray, + draft: &mut Draft, + warnings: &mut Vec, + total_decoded: &mut usize, +) -> Result<(), IoError> { + let Some(_) = array.kind else { + if array.optional_auxiliary { + return Ok(()); + } + push_warning( + warnings, + format!( + "Chromatogram {} contains an unsupported auxiliary binary array; it was skipped.", + draft.native_id + ), + ); + return Ok(()); + }; + let (kind, values) = decode_array(array, draft.declared_len, &draft.native_id, total_decoded)?; + let target = match kind { + ArrayKind::Time => &mut draft.time_min, + ArrayKind::Intensity => &mut draft.values, + ArrayKind::Mz => return Err(invalid("chromatogram contains an unexpected m/z array")), + }; + if target.replace(values).is_some() { + return Err(invalid(format!( + "chromatogram {} repeats a required binary array", + draft.native_id + ))); + } + Ok(()) +} + +fn finish(mut draft: Draft) -> Result { + let mut time_min = draft.time_min.take().ok_or_else(|| { + invalid(format!( + "chromatogram {} is missing the time array", + draft.native_id + )) + })?; + let values = draft.values.take().ok_or_else(|| { + invalid(format!( + "chromatogram {} is missing the intensity array", + draft.native_id + )) + })?; + if time_min.len() != values.len() { + return Err(invalid(format!( + "chromatogram {} has mismatched time and intensity arrays", + draft.native_id + ))); + } + if let Some(declared) = draft.declared_len + && declared != time_min.len() + { + return Err(invalid(format!( + "chromatogram {} declares {declared} points but decodes {}", + draft.native_id, + time_min.len() + ))); + } + match draft.time_unit.as_deref() { + Some("UO:0000010") => time_min.iter_mut().for_each(|value| *value /= 60.0), + Some("UO:0000031") | Some("MS:1000038") | None => {} + Some(unit) => { + return Err(invalid(format!( + "unsupported chromatogram time unit {unit}" + ))); + } + } + let description = if matches!( + draft.kind, + ChromatogramKind::SelectedIonMonitoring | ChromatogramKind::SelectedReactionMonitoring + ) { + draft.native_id.clone() + } else { + draft.title.unwrap_or_else(|| draft.native_id.clone()) + }; + let transition = (draft.precursor_mz.is_some() + || draft.product_mz.is_some() + || draft.collision_energy.is_some() + || draft.activation_method.is_some()) + .then_some(MassTransition { + precursor_mz: draft.precursor_mz, + product_mz: draft.product_mz, + collision_energy: draft.collision_energy, + activation_method: draft.activation_method, + }); + Ok(ChromatogramChannel { + id: ChromatogramChannelId(draft.native_id), + kind: draft.kind, + polarity: draft.polarity, + transition, + source_stream: None, + coordinate: None, + description, + unit: draft.unit.unwrap_or_else(|| "intensity".to_owned()), + time_min, + values, + }) +} diff --git a/crates/io/src/mzml_tests.rs b/crates/io/src/mzml_tests.rs index a10e762..71159a1 100644 --- a/crates/io/src/mzml_tests.rs +++ b/crates/io/src/mzml_tests.rs @@ -99,6 +99,20 @@ fn parsed(xml: String) -> MassSpecRun { parse(Cursor::new(xml), "fixture.mzML".to_owned()).unwrap() } +fn chromatogram(id: &str, kind: &str, details: &str) -> String { + format!( + "{details}{}{}", + array("MS:1000595", &[30.0, 90.0], TestPrecision::F64, false).replace( + "", + "" + ), + array("MS:1000515", &[10.0, 20.0], TestPrecision::F64, false).replace( + "", + "" + ) + ) +} + #[test] fn imports_uncompressed_f64_and_normalizes_seconds_and_minutes() { let run = parsed(document( @@ -127,6 +141,56 @@ fn imports_zlib_f32_into_f64() { assert_eq!(run.streams[0].spectra[0].polarity, Polarity::Negative); } +#[test] +fn imports_chromatogram_only_tic_and_structured_srm_transition() { + let tic = chromatogram("TIC", "MS:1000235", ""); + let transition = chromatogram( + "Q1=455.2 Q3=520.2 peptide-heavy", + "MS:1001473", + "", + ); + let run = parsed(format!( + "{tic}{transition}" + )); + + assert!(run.streams.is_empty()); + assert_eq!(run.chromatograms.len(), 2); + assert_eq!( + run.chromatograms[0].kind, + crate::ChromatogramKind::TotalIonCurrent + ); + assert_eq!(run.chromatograms[0].time_min, [0.5, 1.5]); + assert_eq!(run.chromatograms[0].unit, "count per second"); + let srm = &run.chromatograms[1]; + assert_eq!( + srm.kind, + crate::ChromatogramKind::SelectedReactionMonitoring + ); + assert_eq!(srm.polarity, Polarity::Positive); + let transition = srm.transition.as_ref().unwrap(); + assert_eq!(transition.precursor_mz, Some(455.2)); + assert_eq!(transition.product_mz, Some(520.2)); + assert_eq!(transition.collision_energy, Some(27.5)); + assert_eq!( + transition.activation_method.as_deref(), + Some("collision-induced dissociation") + ); +} + +#[test] +fn ignores_declared_non_standard_chromatogram_arrays_without_warning() { + let auxiliary = "AQAAAAIAAAA="; + let channel = chromatogram("TIC", "MS:1000235", "").replace( + "", + &format!("{auxiliary}"), + ); + let run = parsed(format!( + "{channel}" + )); + assert!(run.import_warnings.is_empty()); + assert_eq!(run.chromatograms[0].values, [10.0, 20.0]); +} + #[test] fn groups_by_ms_level_and_polarity_with_stable_ids() { let xml = document( diff --git a/crates/io/src/sciex_wiff_tic.rs b/crates/io/src/sciex_wiff_tic.rs index 1a62793..3da035e 100644 --- a/crates/io/src/sciex_wiff_tic.rs +++ b/crates/io/src/sciex_wiff_tic.rs @@ -49,7 +49,9 @@ pub(super) fn channels( }; Ok(ChromatogramChannel { id: ChromatogramChannelId(format!("{prefix}{local}")), - kind: ChromatogramKind::Unknown, + kind: ChromatogramKind::TotalIonCurrent, + polarity: source.map_or(crate::Polarity::Unknown, AcquisitionStream::polarity), + transition: None, source_stream: source.map(|s| s.id), coordinate: Some((experiment + 1) as f64), description: format!("{sample} experiment {} total ion current", experiment + 1), diff --git a/crates/io/src/waters.rs b/crates/io/src/waters.rs index 382ab35..0af57e9 100644 --- a/crates/io/src/waters.rs +++ b/crates/io/src/waters.rs @@ -619,6 +619,8 @@ fn optical_channels(functions: &[DecodedFunction]) -> Result