diff --git a/crates/app/src/ui/scientific_script.rs b/crates/app/src/ui/scientific_script.rs index 4bbb022..d9d5014 100644 --- a/crates/app/src/ui/scientific_script.rs +++ b/crates/app/src/ui/scientific_script.rs @@ -10,7 +10,7 @@ use std::path::Path; use plotx_analysis::peaks::{DetectParams, detect_peaks, estimate_noise}; #[cfg(test)] use plotx_io::Acquisition; -use plotx_io::{ChromatogramKind, LiquidChromatographyMethod, MassSpecRun}; +use plotx_io::{AcquisitionStreamId, ChromatogramKind, LiquidChromatographyMethod, MassSpecRun}; use rhai::module_resolvers::DummyModuleResolver; use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position}; @@ -111,6 +111,8 @@ pub(crate) fn prepare_run( ChromatogramKind::Housekeeping => "housekeeping", ChromatogramKind::Unknown => "unknown", }, + "provenance": channel.provenance.machine_label(), + "source_stream_id": channel.source_stream.map(AcquisitionStreamId::get), "description": channel.description, "polarity": match channel.polarity { plotx_io::Polarity::Positive => "positive", @@ -130,6 +132,18 @@ pub(crate) fn prepare_run( }) }) .collect::>(); + let stream_chromatograms = run + .streams + .iter() + .flat_map(|stream| { + [ + ChromatogramKind::TotalIonCurrent, + ChromatogramKind::BasePeak, + ] + .into_iter() + .filter_map(move |kind| prepared_stream_chromatogram(run, stream.id, kind)) + }) + .collect::>(); let scans = run .streams .iter() @@ -170,11 +184,52 @@ pub(crate) fn prepare_run( "source": run.source, "instrument": run.instrument, "chromatograms": channels, + "stream_chromatograms": stream_chromatograms, "scans": scans, "lc_method": method, }) } +fn prepared_stream_chromatogram( + run: &MassSpecRun, + stream: AcquisitionStreamId, + kind: ChromatogramKind, +) -> Option { + let provenance = run.stream_chromatogram_provenance(stream, kind)?; + let source = run.bound_chromatogram(stream, kind); + let spectra = &run.stream(stream)?.spectra; + let (time_min, values) = if let Some(channel) = source { + (channel.time_min.clone(), channel.values.clone()) + } else { + ( + spectra + .iter() + .map(|spectrum| spectrum.retention_time_min) + .collect(), + spectra + .iter() + .map(|spectrum| match kind { + ChromatogramKind::TotalIonCurrent => spectrum.tic, + ChromatogramKind::BasePeak => spectrum.base_peak_intensity.unwrap_or(0.0), + _ => unreachable!("caller restricts resolved stream chromatograms"), + }) + .collect(), + ) + }; + Some(serde_json::json!({ + "stream_id": stream.get(), + "kind": match kind { + ChromatogramKind::TotalIonCurrent => "total_ion_current", + ChromatogramKind::BasePeak => "base_peak", + _ => unreachable!("caller restricts resolved stream chromatograms"), + }, + "provenance": provenance.machine_label(), + "source_channel_id": source.map(|channel| channel.id.0.as_str()), + "time_min": time_min, + "values": values, + })) +} + fn summary_provenance_label(provenance: plotx_io::SpectrumSummaryProvenance) -> &'static str { match provenance { plotx_io::SpectrumSummaryProvenance::Source => "source", @@ -325,7 +380,19 @@ mod tests { }), }], }], - chromatograms: Vec::new(), + chromatograms: vec![plotx_io::ChromatogramChannel { + id: plotx_io::ChromatogramChannelId("bpc".to_owned()), + kind: ChromatogramKind::BasePeak, + provenance: plotx_io::ChromatogramProvenance::Source, + polarity: plotx_io::Polarity::Positive, + transition: None, + source_stream: Some(plotx_io::AcquisitionStreamId::new(1)), + coordinate: None, + description: "Base-peak chromatogram".to_owned(), + unit: "count".to_owned(), + time_min: vec![1.5], + values: vec![50.0], + }], import_warnings: Vec::new(), }; @@ -341,6 +408,20 @@ mod tests { 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"); + assert_eq!( + prepared["chromatograms"][0]["provenance"], + "source_chromatogram" + ); + assert_eq!(prepared["chromatograms"][0]["source_stream_id"], 1); + assert_eq!( + prepared["stream_chromatograms"][0]["provenance"], + "spectrum_summary" + ); + assert_eq!( + prepared["stream_chromatograms"][1]["provenance"], + "source_chromatogram" + ); + assert_eq!(prepared["stream_chromatograms"][1]["values"][0], 50.0); } #[test] diff --git a/crates/app/src/ui/tools/mass_spec_browser_tests.rs b/crates/app/src/ui/tools/mass_spec_browser_tests.rs index ee0e822..2cc13b0 100644 --- a/crates/app/src/ui/tools/mass_spec_browser_tests.rs +++ b/crates/app/src/ui/tools/mass_spec_browser_tests.rs @@ -1,6 +1,5 @@ use super::*; -use plotx_io::{MassTransition, Polarity}; -use std::path::Path; +use plotx_io::{ChromatogramProvenance, MassTransition, Polarity}; fn channel( id: &str, @@ -12,6 +11,7 @@ fn channel( ChromatogramChannel { id: ChromatogramChannelId(id.to_owned()), kind, + provenance: ChromatogramProvenance::Source, polarity, transition: transition.map( |(precursor_mz, product_mz, collision_energy, activation_method)| MassTransition { @@ -172,57 +172,3 @@ fn large_channel_cache_scans_only_when_filters_change() { assert_eq!(state.matches.len(), 1); assert_eq!(state.matches[0].id.0, "transition=719"); } - -#[test] -fn local_pxd066465_builds_720_transition_fields_when_present() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join(".tmp/MS-data/pride-PXD066465/Drug_substance_3_scheduled_MRM.mzML"); - if !path.is_file() { - return; - } - let loaded = plotx_io::mzml::load(&path).expect("PXD066465 mzML import"); - let plotx_io::Acquisition::MassSpec(run) = loaded.acquisition else { - panic!("PXD066465 did not import as mass spectrometry data"); - }; - let dataset = MassSpecDataset::load(*run); - let index = ChannelIndex::build(&dataset); - - assert_eq!(dataset.run.streams.len(), 0); - assert_eq!(dataset.run.chromatograms.len(), 722); - assert_eq!(index.entries.len(), 722); - assert_eq!(index.transition_count, 720); - assert!( - index - .entries - .iter() - .all(|entry| dataset.channel_field_id(&entry.id).is_some()) - ); - - let first_transition = index - .entries - .iter() - .find(|entry| entry.precursor_mz.is_some() && entry.product_mz.is_some()) - .expect("structured transition"); - let precursor_mz = first_transition.precursor_mz.unwrap(); - let product_mz = first_transition.product_mz.unwrap(); - let mut state = BrowserState::new(index); - state.filters.precursor_mz = format_number(precursor_mz); - state.filters.product_mz = format_number(product_mz); - assert!(state.refresh()); - assert!(!state.matches.is_empty()); - - let ctx = crate::typography::test_context(); - let mut rendered_counts = None; - let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.set_width(360.0); - let selected = dataset.channel_field_id(&dataset.run.chromatograms[0].id); - assert!(channel_browser(&dataset, selected, ui).is_none()); - let state_id = ui.make_persistent_id(("mass_spec_channel_browser", dataset.resource_id)); - let rendered = ui - .data(|data| data.get_temp::(state_id)) - .expect("browser UI cache"); - rendered_counts = Some((rendered.index.entries.len(), rendered.matches.len())); - }); - assert_eq!(rendered_counts, Some((722, 722))); -} diff --git a/crates/core/src/project/mass_spec_convert.rs b/crates/core/src/project/mass_spec_convert.rs index 4f6a41b..32c9ec9 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, SpectrumAcquisition, - SpectrumId, SpectrumRepresentation, SpectrumSummaryProvenance, StreamRole, + ChromatogramKind, ChromatogramProvenance, MassSpecRun, MassSpectrum, Polarity, Precursor, + SpectrumAcquisition, SpectrumId, SpectrumRepresentation, SpectrumSummaryProvenance, StreamRole, }; use std::collections::BTreeMap; use std::io::{Read, Write}; @@ -694,6 +694,7 @@ impl<'a, 'p, R: Read> Reader<'a, 'p, R> { Ok(ChromatogramChannel { id, kind, + provenance: ChromatogramProvenance::Source, polarity, transition, source_stream, diff --git a/crates/core/src/project/mass_spec_convert_project_tests.rs b/crates/core/src/project/mass_spec_convert_project_tests.rs index 99cbb8c..ebe1ade 100644 --- a/crates/core/src/project/mass_spec_convert_project_tests.rs +++ b/crates/core/src/project/mass_spec_convert_project_tests.rs @@ -43,6 +43,13 @@ fn schema_v1_project_round_trip_preserves_stream_bindings_and_extractions() { assert_eq!(loaded.extracted_ion_chromatograms[0].mz_min, 10.0); assert_eq!(loaded.extracted_ion_chromatograms[0].intensity, [0.0, 0.0]); assert_eq!(loaded.field_catalog, expected_catalog); + assert!( + loaded + .run + .chromatograms + .iter() + .all(|channel| channel.provenance == ChromatogramProvenance::Source) + ); } #[test] diff --git a/crates/core/src/project/mass_spec_convert_tests.rs b/crates/core/src/project/mass_spec_convert_tests.rs index 07c60e5..f23761e 100644 --- a/crates/core/src/project/mass_spec_convert_tests.rs +++ b/crates/core/src/project/mass_spec_convert_tests.rs @@ -76,6 +76,59 @@ fn rejects_large_structural_counts_without_reserving_the_claimed_collection() { assert_count_rejected_without_payload(&spectra, "spectrum count"); } +#[test] +fn v1_channel_wire_layout_remains_fixed_and_defaults_provenance_to_source() { + let channel = ChromatogramChannel { + id: ChromatogramChannelId("legacy-tic".to_owned()), + kind: ChromatogramKind::TotalIonCurrent, + provenance: ChromatogramProvenance::PeakArrays, + polarity: Polarity::Unknown, + transition: None, + source_stream: None, + coordinate: None, + description: "Legacy TIC".to_owned(), + unit: "count".to_owned(), + time_min: vec![0.5], + values: vec![10.0], + }; + let run = MassSpecRun { + source: String::new(), + metadata: BTreeMap::new(), + instrument: None, + streams: Vec::new(), + chromatograms: vec![channel], + import_warnings: Vec::new(), + }; + + let mut v1 = minimal_run_prefix(); + v1.extend_from_slice(&0_u64.to_le_bytes()); // import warnings + v1.extend_from_slice(&0_u64.to_le_bytes()); // streams + v1.extend_from_slice(&1_u64.to_le_bytes()); // chromatograms + write_string(&mut v1, "legacy-tic").unwrap(); + v1.push(0); // TIC + v1.push(2); // unknown polarity + v1.push(0); // no transition + v1.push(0); // no source stream + v1.push(0); // no coordinate + write_string(&mut v1, "Legacy TIC").unwrap(); + write_string(&mut v1, "count").unwrap(); + write_f64s(&mut v1, &[0.5]).unwrap(); + write_f64s(&mut v1, &[10.0]).unwrap(); + v1.extend_from_slice(&0_u64.to_le_bytes()); // no active stream + v1.extend_from_slice(&0_u64.to_le_bytes()); // extracted spectra + v1.extend_from_slice(&1_u64.to_le_bytes()); // next extraction ID + v1.extend_from_slice(&0_u64.to_le_bytes()); // extracted-ion chromatograms + v1.extend_from_slice(&1_u64.to_le_bytes()); // next chromatogram ID + + assert_eq!(encode(&run).unwrap(), v1); + let decoded = decode_bytes(&v1).unwrap(); + assert_eq!(decoded.chromatograms[0].polarity, Polarity::Unknown); + assert_eq!( + decoded.chromatograms[0].provenance, + ChromatogramProvenance::Source + ); +} + #[test] fn payload_round_trips_spectra_channels_precursors_and_transitions() { let mut run = crate::state::sample_mass_spec_run(); @@ -99,6 +152,7 @@ fn payload_round_trips_spectra_channels_precursors_and_transitions() { activation_method: Some("CID".to_owned()), }); run.chromatograms[0].kind = ChromatogramKind::SelectedReactionMonitoring; + run.chromatograms[0].provenance = ChromatogramProvenance::SpectrumSummary; run.chromatograms[0].polarity = Polarity::Positive; run.chromatograms[0].transition = Some(plotx_io::MassTransition { precursor_mz: Some(445.2), @@ -146,6 +200,7 @@ fn payload_round_trips_spectra_channels_precursors_and_transitions() { assert_eq!(precursor.activation_method.as_deref(), Some("CID")); let channel = &decoded.chromatograms[0]; assert_eq!(channel.kind, ChromatogramKind::SelectedReactionMonitoring); + assert_eq!(channel.provenance, ChromatogramProvenance::Source); assert_eq!(channel.polarity, Polarity::Positive); let transition = channel.transition.as_ref().unwrap(); assert_eq!(transition.precursor_mz, Some(445.2)); @@ -180,4 +235,8 @@ fn chromatogram_only_payload_round_trips_with_no_active_stream() { let decoded = decode_bytes(&encode(&run).unwrap()).unwrap(); assert!(decoded.streams.is_empty()); assert_eq!(decoded.chromatograms.len(), 1); + assert_eq!( + decoded.chromatograms[0].provenance, + ChromatogramProvenance::Source + ); } diff --git a/crates/core/src/state/dataset_trace.rs b/crates/core/src/state/dataset_trace.rs index 0df3457..9704686 100644 --- a/crates/core/src/state/dataset_trace.rs +++ b/crates/core/src/state/dataset_trace.rs @@ -195,27 +195,19 @@ impl Dataset { } Self::Afm(_) => None, Self::MassSpec(data) => { - let stream = data.run.stream(data.active_stream)?; - let chromatogram = - super::mass_spec_tic::points_for_stream_tic(&data.run, data.active_stream); - if let Some(points) = chromatogram { - let (xs, ys): (Vec<_>, Vec<_>) = points - .into_iter() - .map(|[time, value]| (time, value)) - .unzip(); - return Some(Trace1d { - xs, - ys, - x_reversed: false, - }); - } + let chromatogram = super::mass_spec_tic::resolve_stream_chromatogram( + &data.run, + data.active_stream, + plotx_io::ChromatogramKind::TotalIonCurrent, + )?; + let (xs, ys) = chromatogram + .points + .into_iter() + .map(|[time, value]| (time, value)) + .unzip(); Some(Trace1d { - xs: stream - .spectra - .iter() - .map(|scan| scan.retention_time_min) - .collect(), - ys: stream.spectra.iter().map(|scan| scan.tic).collect(), + xs, + ys, x_reversed: false, }) } diff --git a/crates/core/src/state/field_mass_spec.rs b/crates/core/src/state/field_mass_spec.rs index 76f640c..075647e 100644 --- a/crates/core/src/state/field_mass_spec.rs +++ b/crates/core/src/state/field_mass_spec.rs @@ -1,6 +1,9 @@ use super::*; use crate::automation::{CAP_FIELD_MASS_CHROMATOGRAM, CAP_FIELD_MASS_SPECTRUM, CapabilityId}; -use crate::state::{FieldMetadata, MassSpecDataset, readable_ms_stream}; +use crate::state::{ + FieldMetadata, MASS_SPEC_CHROMATOGRAM_PROVENANCE_METADATA_KEY, MassSpecDataset, + readable_ms_stream, +}; pub(super) fn default_field_id(dataset: &MassSpecDataset) -> Option { dataset @@ -31,12 +34,11 @@ pub(super) fn descriptor(dataset: &MassSpecDataset, id: FieldId) -> Option Option Option, +) -> FieldDescriptor { + let mut descriptor = build( + dataset, + id, + key, + name, + CAP_FIELD_MASS_CHROMATOGRAM, + length, + units, + ); + if let Some(provenance) = dataset.chromatogram_provenance_for_field(id) { + descriptor.metadata.0.insert( + MASS_SPEC_CHROMATOGRAM_PROVENANCE_METADATA_KEY.to_owned(), + provenance.machine_label().to_owned(), + ); + } + descriptor +} + fn build( dataset: &MassSpecDataset, id: FieldId, diff --git a/crates/core/src/state/mass_spec.rs b/crates/core/src/state/mass_spec.rs index c74a42d..5bc1a96 100644 --- a/crates/core/src/state/mass_spec.rs +++ b/crates/core/src/state/mass_spec.rs @@ -1,4 +1,4 @@ -use super::mass_spec_tic::points_for_stream_tic; +use super::mass_spec_tic::resolve_stream_chromatogram; use super::{ DatasetId, DatasetLineage, FieldCatalog, FieldId, mass_spec_xic::{ExtractedIonChromatogram, IonChromatogramId, xic_key, xic_title}, @@ -6,8 +6,8 @@ use super::{ }; use plotx_figure::{Axis, Figure, Series, SeriesKind}; use plotx_io::{ - AcquisitionStreamId, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, MassSpecRun, - MassSpectrum, SpectrumId, StreamRole, + AcquisitionStreamId, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, + ChromatogramProvenance, MassSpecRun, MassSpectrum, SpectrumId, StreamRole, }; use serde::{Deserialize, Serialize}; use std::fmt; @@ -35,6 +35,8 @@ impl fmt::Display for ExtractionId { } pub(crate) type MassSpecFieldValues = (String, &'static str, String, Vec<[f64; 2]>, bool); +pub const MASS_SPEC_CHROMATOGRAM_PROVENANCE_METADATA_KEY: &str = + "mass_spec.chromatogram_provenance"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -95,6 +97,26 @@ impl MassSpecDataset { .find(|channel| self.channel_field_id(&channel.id) == Some(field)) } + pub fn chromatogram_provenance_for_field( + &self, + field: FieldId, + ) -> Option { + if let Some(channel) = self.channel_for_field(field) { + return Some(channel.provenance); + } + self.supported_ms_streams().find_map(|stream| { + let kind = if self.field_catalog.id_for_key(&stream_tic_key(stream)) == Some(field) { + ChromatogramKind::TotalIonCurrent + } else if self.field_catalog.id_for_key(&stream_bpi_key(stream)) == Some(field) { + ChromatogramKind::BasePeak + } else { + return None; + }; + resolve_stream_chromatogram(&self.run, stream, kind) + .map(|chromatogram| chromatogram.provenance) + }) + } + /// Resolve a chromatogram field to the stream whose scan cursor it drives. /// Optical channels intentionally use the active MS stream. pub fn chromatogram_stream_for_field(&self, field: FieldId) -> Option { @@ -449,36 +471,27 @@ impl MassSpecDataset { let stream_id = stream.id; let stream_label = stream_display_label(stream); if self.field_catalog.id_for_key(&stream_tic_key(stream_id)) == Some(id) { - let chromatogram_points = points_for_stream_tic(&self.run, stream_id); + let chromatogram = resolve_stream_chromatogram( + &self.run, + stream_id, + ChromatogramKind::TotalIonCurrent, + )?; return Some(( format!("{stream_label} TIC"), "Retention time (min)", "Total ion current".to_owned(), - chromatogram_points.unwrap_or_else(|| { - stream - .spectra - .iter() - .map(|scan| [scan.retention_time_min, scan.tic]) - .collect() - }), + chromatogram.points, false, )); } if self.field_catalog.id_for_key(&stream_bpi_key(stream_id)) == Some(id) { + let chromatogram = + resolve_stream_chromatogram(&self.run, stream_id, ChromatogramKind::BasePeak)?; return Some(( format!("{stream_label} BPI"), "Retention time (min)", "Base-peak intensity".to_owned(), - stream - .spectra - .iter() - .map(|scan| { - [ - scan.retention_time_min, - scan.base_peak_intensity.unwrap_or(0.0), - ] - }) - .collect(), + chromatogram.points, false, )); } diff --git a/crates/core/src/state/mass_spec_fixture.rs b/crates/core/src/state/mass_spec_fixture.rs index 92bf0f3..f55236f 100644 --- a/crates/core/src/state/mass_spec_fixture.rs +++ b/crates/core/src/state/mass_spec_fixture.rs @@ -1,7 +1,7 @@ use super::*; use plotx_io::{ - AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, Polarity, SpectrumAcquisition, - SpectrumRepresentation, SpectrumSummaryProvenance, + AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, ChromatogramProvenance, + Polarity, SpectrumAcquisition, SpectrumRepresentation, SpectrumSummaryProvenance, }; pub(crate) fn sample_mass_spec_run() -> MassSpecRun { @@ -105,6 +105,7 @@ fn channel( ChromatogramChannel { id: ChromatogramChannelId(id.to_owned()), kind, + provenance: ChromatogramProvenance::Source, polarity: Polarity::Unknown, transition: None, source_stream: None, diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index fd2c2b2..6c74c54 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -3,8 +3,7 @@ use crate::actions::Action; use crate::state::{ AxisRange, Dataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesSource, ToolGroup, }; -use plotx_io::{ChromatogramChannel, ChromatogramChannelId}; -use std::path::Path; +use plotx_io::{ChromatogramChannel, ChromatogramChannelId, ChromatogramProvenance}; #[test] fn dynamic_catalog_and_stable_selection_follow_stream_identity() { @@ -117,6 +116,7 @@ fn stream_tic_prefers_bound_chromatogram_points() { run.chromatograms.push(ChromatogramChannel { id: ChromatogramChannelId("tic:bound".to_owned()), kind: ChromatogramKind::TotalIonCurrent, + provenance: ChromatogramProvenance::Source, polarity: plotx_io::Polarity::Unknown, transition: None, source_stream: Some(AcquisitionStreamId::new(3)), @@ -134,10 +134,14 @@ fn stream_tic_prefers_bound_chromatogram_points() { let (_, _, _, points, stick) = dataset.field_values(field).expect("TIC values"); assert!(!stick); assert_eq!(points, [[0.0, 11.0], [2.0, 22.0]]); + assert_eq!( + dataset.chromatogram_provenance_for_field(field), + Some(ChromatogramProvenance::Source) + ); } #[test] -fn stream_tic_without_a_bound_channel_uses_spectrum_summaries() { +fn stream_tic_without_a_bound_channel_uses_peak_arrays() { let dataset = MassSpecDataset::load(sample_mass_spec_run()); let field = dataset .field_catalog @@ -147,6 +151,22 @@ fn stream_tic_without_a_bound_channel_uses_spectrum_summaries() { assert!(!stick); assert_eq!(points, [[0.5, 2.0], [1.0, 9.0]]); + assert_eq!( + dataset.chromatogram_provenance_for_field(field), + Some(ChromatogramProvenance::PeakArrays) + ); + let bpc = dataset + .field_catalog + .id_for_key(&stream_bpi_key(AcquisitionStreamId::new(3))) + .expect("stream BPC field"); + assert_eq!( + dataset.field_values(bpc).unwrap().3, + [[0.5, 2.0], [1.0, 9.0]] + ); + assert_eq!( + dataset.chromatogram_provenance_for_field(bpc), + Some(ChromatogramProvenance::PeakArrays) + ); assert_eq!( dataset.run.streams[0].spectra[1] .intensity @@ -157,34 +177,82 @@ fn stream_tic_without_a_bound_channel_uses_spectrum_summaries() { } #[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; +fn stream_tic_and_bpc_choose_independent_sources_and_fallbacks() { + let mut run = sample_mass_spec_run(); + for spectrum in &mut run.streams[0].spectra { + spectrum.tic_provenance = plotx_io::SpectrumSummaryProvenance::Source; } - 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 + run.chromatograms.push(ChromatogramChannel { + id: ChromatogramChannelId("bpc:bound".to_owned()), + kind: ChromatogramKind::BasePeak, + provenance: ChromatogramProvenance::Source, + polarity: plotx_io::Polarity::Positive, + transition: None, + source_stream: Some(AcquisitionStreamId::new(3)), + coordinate: None, + description: "Base-peak chromatogram".to_owned(), + unit: "cps".to_owned(), + time_min: vec![0.25, 1.25], + values: vec![101.0, 202.0], + }); + run.chromatograms.push(ChromatogramChannel { + id: ChromatogramChannelId("unknown:bound".to_owned()), + kind: ChromatogramKind::Unknown, + provenance: ChromatogramProvenance::Source, + polarity: plotx_io::Polarity::Unknown, + transition: None, + source_stream: Some(AcquisitionStreamId::new(3)), + coordinate: None, + description: "Unclassified signal".to_owned(), + unit: "cps".to_owned(), + time_min: vec![9.0], + values: vec![999.0], + }); + let dataset = MassSpecDataset::load(run); + let tic = dataset + .field_catalog + .id_for_key(&stream_tic_key(AcquisitionStreamId::new(3))) + .unwrap(); + let bpc = dataset .field_catalog - .id_for_key(&stream_tic_key(dataset.active_stream)) + .id_for_key(&stream_bpi_key(AcquisitionStreamId::new(3))) .unwrap(); - let figure = dataset.field_figure(field).unwrap(); - assert_eq!(figure.series[0].points.len(), 14); assert_eq!( - figure.series[0] - .points + dataset.field_values(tic).unwrap().3, + [[0.5, 2.0], [1.0, 9.0]] + ); + assert_eq!( + dataset.chromatogram_provenance_for_field(tic), + Some(ChromatogramProvenance::SpectrumSummary) + ); + assert_eq!( + dataset.field_values(bpc).unwrap().3, + [[0.25, 101.0], [1.25, 202.0]] + ); + assert_eq!( + dataset.chromatogram_provenance_for_field(bpc), + Some(ChromatogramProvenance::Source) + ); +} + +#[test] +fn scientific_summary_exposes_resolved_chromatogram_provenance() { + let mass_spec = MassSpecDataset::load(sample_mass_spec_run()); + let dataset = Dataset::MassSpec(Box::new(mass_spec)); + let mut app = PlotxApp::new(); + app.doc.canvases.push(crate::workflow::build_default_canvas( + &dataset, + "synthetic.raw", + )); + app.doc.datasets.push(dataset); + + assert!( + app.canvas_scientific_summary(0) + .formatted_lines() .iter() - .copied() - .max_by(|left, right| left[1].total_cmp(&right[1])), - Some([0.285483333333, 22_136_832.0]) + .any(|line| line.contains("peak arrays")) ); - assert_eq!(figure.series[0].points[1], [0.007896666667, 12_901_166.0]); } #[test] @@ -193,6 +261,7 @@ fn displayed_mass_spec_trace_uses_bound_tic_channel() { run.chromatograms.push(ChromatogramChannel { id: ChromatogramChannelId("tic:rendered".to_owned()), kind: ChromatogramKind::TotalIonCurrent, + provenance: ChromatogramProvenance::Source, polarity: plotx_io::Polarity::Unknown, transition: None, source_stream: Some(AcquisitionStreamId::new(3)), diff --git a/crates/core/src/state/mass_spec_tic.rs b/crates/core/src/state/mass_spec_tic.rs index 61fe6fc..dabfc6f 100644 --- a/crates/core/src/state/mass_spec_tic.rs +++ b/crates/core/src/state/mass_spec_tic.rs @@ -1,26 +1,46 @@ -use plotx_io::{AcquisitionStreamId, MassSpecRun}; +use plotx_io::{AcquisitionStreamId, ChromatogramKind, ChromatogramProvenance, MassSpecRun}; -pub(crate) fn points_for_stream_tic( +pub(crate) struct ResolvedChromatogram { + pub points: Vec<[f64; 2]>, + pub provenance: ChromatogramProvenance, +} + +pub(crate) fn resolve_stream_chromatogram( run: &MassSpecRun, stream_id: AcquisitionStreamId, -) -> Option> { - 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; + kind: ChromatogramKind, +) -> Option { + let provenance = run.stream_chromatogram_provenance(stream_id, kind)?; + if let Some(channel) = run.bound_chromatogram(stream_id, kind) { + return Some(ResolvedChromatogram { + points: channel + .time_min + .iter() + .copied() + .zip(channel.values.iter().copied()) + .map(|(time, value)| [time, value]) + .collect(), + provenance, + }); } - Some( - channel - .time_min + let stream = run.stream(stream_id)?; + let points = match kind { + ChromatogramKind::TotalIonCurrent => stream + .spectra + .iter() + .map(|spectrum| [spectrum.retention_time_min, spectrum.tic]) + .collect(), + ChromatogramKind::BasePeak => stream + .spectra .iter() - .copied() - .zip(channel.values.iter().copied()) - .map(|(time, value)| [time, value]) + .map(|spectrum| { + [ + spectrum.retention_time_min, + spectrum.base_peak_intensity.unwrap_or(0.0), + ] + }) .collect(), - ) + _ => return None, + }; + Some(ResolvedChromatogram { points, provenance }) } diff --git a/crates/core/src/state/scientific_summary/resolver.rs b/crates/core/src/state/scientific_summary/resolver.rs index ff33a32..957ab20 100644 --- a/crates/core/src/state/scientific_summary/resolver.rs +++ b/crates/core/src/state/scientific_summary/resolver.rs @@ -430,10 +430,26 @@ fn mass_spec_context( data: &crate::state::MassSpecDataset, field: crate::state::FieldId, ) -> Option { - let stream_id = data + let provenance = data + .chromatogram_provenance_for_field(field) + .map(|provenance| { + SummaryPart::new( + format!( + "mass:chromatogram-provenance:{}", + provenance.machine_label() + ), + provenance.display_label(), + ) + }); + let Some(stream_id) = data .chromatogram_stream_for_field(field) - .or_else(|| data.spectrum_stream_for_field(field))?; - let stream = data.run.stream(stream_id)?; + .or_else(|| data.spectrum_stream_for_field(field)) + else { + return provenance; + }; + let Some(stream) = data.run.stream(stream_id) else { + return provenance; + }; let polarity = match stream.polarity() { plotx_io::Polarity::Positive => "positive", plotx_io::Polarity::Negative => "negative", @@ -444,12 +460,12 @@ fn mass_spec_context( (false, Some(level)) => format!("{polarity} MS{level}"), (false, None) => polarity.to_owned(), (true, Some(level)) => format!("MS{level}"), - (true, None) => return None, + (true, None) => return provenance, }; - Some(SummaryPart::new( - format!("mass:{}:{:?}", polarity, level), - text, - )) + let acquisition = SummaryPart::new(format!("mass:{}:{:?}", polarity, level), text); + provenance + .map(|provenance| merge_context(Some(acquisition.clone()), provenance).unwrap()) + .or(Some(acquisition)) } fn xps_summary( diff --git a/crates/io/src/mass_spec.rs b/crates/io/src/mass_spec.rs index ed11da8..508615f 100644 --- a/crates/io/src/mass_spec.rs +++ b/crates/io/src/mass_spec.rs @@ -71,6 +71,35 @@ pub enum SpectrumSummaryProvenance { Derived, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChromatogramProvenance { + Source, + SpectrumSummary, + PeakArrays, + SpectrumSummaryAndPeakArrays, +} + +impl ChromatogramProvenance { + pub const fn machine_label(self) -> &'static str { + match self { + Self::Source => "source_chromatogram", + Self::SpectrumSummary => "spectrum_summary", + Self::PeakArrays => "peak_arrays", + Self::SpectrumSummaryAndPeakArrays => "spectrum_summary_and_peak_arrays", + } + } + + pub const fn display_label(self) -> &'static str { + match self { + Self::Source => "source chromatogram", + Self::SpectrumSummary => "spectrum summaries", + Self::PeakArrays => "peak arrays", + Self::SpectrumSummaryAndPeakArrays => "spectrum summaries and peak arrays", + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SpectrumAcquisition { pub instrument_configuration_id: Option, @@ -174,6 +203,7 @@ pub struct MassTransition { pub struct ChromatogramChannel { pub id: ChromatogramChannelId, pub kind: ChromatogramKind, + pub provenance: ChromatogramProvenance, pub polarity: Polarity, pub transition: Option, pub source_stream: Option, @@ -279,6 +309,51 @@ impl MassSpecRun { self.streams.iter().find(|stream| stream.id == id) } + pub fn bound_chromatogram( + &self, + stream: AcquisitionStreamId, + kind: ChromatogramKind, + ) -> Option<&ChromatogramChannel> { + self.chromatograms + .iter() + .filter(|channel| channel.source_stream == Some(stream) && channel.kind == kind) + .min_by_key(|channel| match channel.provenance { + ChromatogramProvenance::Source => 0, + ChromatogramProvenance::SpectrumSummary => 1, + ChromatogramProvenance::PeakArrays => 2, + ChromatogramProvenance::SpectrumSummaryAndPeakArrays => 3, + }) + } + + pub fn stream_chromatogram_provenance( + &self, + stream: AcquisitionStreamId, + kind: ChromatogramKind, + ) -> Option { + if let Some(channel) = self.bound_chromatogram(stream, kind) { + return Some(channel.provenance); + } + let spectra = &self.stream(stream)?.spectra; + let mut source = false; + let mut derived = false; + for provenance in spectra.iter().map(|spectrum| match kind { + ChromatogramKind::TotalIonCurrent => Some(spectrum.tic_provenance), + ChromatogramKind::BasePeak => Some(spectrum.base_peak_provenance), + _ => None, + }) { + match provenance? { + SpectrumSummaryProvenance::Source => source = true, + SpectrumSummaryProvenance::Derived => derived = true, + } + } + match (source, derived) { + (true, false) => Some(ChromatogramProvenance::SpectrumSummary), + (false, true) => Some(ChromatogramProvenance::PeakArrays), + (true, true) => Some(ChromatogramProvenance::SpectrumSummaryAndPeakArrays), + (false, false) => None, + } + } + pub fn validate(&self) -> Result<(), String> { let mut stream_ids = BTreeSet::new(); for stream in &self.streams { @@ -522,6 +597,7 @@ mod tests { let channel = ChromatogramChannel { id: ChromatogramChannelId("tic".to_owned()), kind: ChromatogramKind::Unknown, + provenance: ChromatogramProvenance::Source, polarity: Polarity::Unknown, transition: None, source_stream: None, diff --git a/crates/io/src/mzml.rs b/crates/io/src/mzml.rs index cc842e0..294e259 100644 --- a/crates/io/src/mzml.rs +++ b/crates/io/src/mzml.rs @@ -228,8 +228,8 @@ pub fn parse(input: impl BufRead, source: String) -> Result Result { Ok(ChromatogramChannel { id: ChromatogramChannelId(draft.native_id), kind: draft.kind, + provenance: ChromatogramProvenance::Source, polarity: draft.polarity, transition, source_stream: None, diff --git a/crates/io/src/mzml_stream.rs b/crates/io/src/mzml_stream.rs index 937332c..b3d6d03 100644 --- a/crates/io/src/mzml_stream.rs +++ b/crates/io/src/mzml_stream.rs @@ -1,4 +1,7 @@ -use crate::{AcquisitionStream, AcquisitionStreamId, MassSpectrum, Polarity, StreamRole}; +use crate::{ + AcquisitionStream, AcquisitionStreamId, ChromatogramChannel, ChromatogramKind, MassSpectrum, + Polarity, StreamRole, +}; use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -27,6 +30,31 @@ pub(super) fn build(spectra: Vec) -> Vec { .collect() } +pub(super) fn bind_source_chromatograms( + streams: &[AcquisitionStream], + chromatograms: &mut [ChromatogramChannel], +) { + let [stream] = streams else { return }; + let mut bound_tic = false; + let mut bound_bpc = false; + for channel in chromatograms { + let bind = match channel.kind { + ChromatogramKind::TotalIonCurrent if !bound_tic => { + bound_tic = true; + true + } + ChromatogramKind::BasePeak if !bound_bpc => { + bound_bpc = true; + true + } + _ => false, + }; + if bind { + channel.source_stream = Some(stream.id); + } + } +} + fn range(spectra: &[MassSpectrum]) -> Option<[f64; 2]> { let mut values = spectra .iter() diff --git a/crates/io/src/mzml_tests.rs b/crates/io/src/mzml_tests.rs index 98f027a..2816f5b 100644 --- a/crates/io/src/mzml_tests.rs +++ b/crates/io/src/mzml_tests.rs @@ -1,8 +1,7 @@ use super::*; -use crate::AcquisitionStreamId; +use crate::{AcquisitionStreamId, ChromatogramKind, ChromatogramProvenance}; use flate2::{Compression, write::ZlibEncoder}; use std::io::{self, BufReader, Cursor, Read, Write}; -use std::path::Path; struct ChunkedRead { inner: R, @@ -240,78 +239,6 @@ fn retains_acquisition_metadata_without_fragmenting_ms_level_streams() { ); } -#[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!( @@ -414,6 +341,11 @@ fn imports_chromatogram_only_tic_and_structured_srm_transition() { run.chromatograms[0].kind, crate::ChromatogramKind::TotalIonCurrent ); + assert_eq!( + run.chromatograms[0].provenance, + crate::ChromatogramProvenance::Source + ); + assert_eq!(run.chromatograms[0].source_stream, None); 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]; @@ -432,6 +364,46 @@ fn imports_chromatogram_only_tic_and_structured_srm_transition() { ); } +#[test] +fn binds_at_most_one_source_tic_and_bpc_when_the_run_has_one_stream() { + let spectrum = spectrum("scan=1", 1, "MS:1000129", false, TestPrecision::F64, false); + let tic = chromatogram("TIC", "MS:1000235", ""); + let extra_tic = chromatogram("TIC duplicate", "MS:1000235", ""); + let bpc = chromatogram("BPC", "MS:1000628", ""); + let extra_bpc = chromatogram("BPC duplicate", "MS:1000628", ""); + let run = parsed(format!( + "{spectrum}{tic}{extra_tic}{bpc}{extra_bpc}" + )); + let stream = run.streams[0].id; + + assert_eq!(run.chromatograms[0].source_stream, Some(stream)); + assert_eq!(run.chromatograms[1].source_stream, None); + assert_eq!(run.chromatograms[2].source_stream, Some(stream)); + assert_eq!(run.chromatograms[3].source_stream, None); + assert_eq!( + run.bound_chromatogram(stream, ChromatogramKind::TotalIonCurrent) + .unwrap() + .id + .0, + "TIC" + ); + assert_eq!( + run.bound_chromatogram(stream, ChromatogramKind::BasePeak) + .unwrap() + .id + .0, + "BPC" + ); + assert_eq!( + run.stream_chromatogram_provenance(stream, ChromatogramKind::TotalIonCurrent), + Some(ChromatogramProvenance::Source) + ); + assert_eq!( + run.stream_chromatogram_provenance(stream, ChromatogramKind::BasePeak), + Some(ChromatogramProvenance::Source) + ); +} + #[test] fn ignores_declared_non_standard_chromatogram_arrays_without_warning() { let auxiliary = "AQAAAAIAAAA="; diff --git a/crates/io/src/sciex_wiff_tests.rs b/crates/io/src/sciex_wiff_tests.rs index 2d915c1..0dab893 100644 --- a/crates/io/src/sciex_wiff_tests.rs +++ b/crates/io/src/sciex_wiff_tests.rs @@ -180,6 +180,17 @@ fn loads_a_synthetic_single_sample_wiff_pair_end_to_end() { assert_eq!(run.chromatograms.len(), 1); assert_eq!(run.chromatograms[0].time_min, vec![1.25]); assert_eq!(run.chromatograms[0].values, vec![14.0]); + assert_eq!( + run.chromatograms[0].provenance, + crate::ChromatogramProvenance::Source + ); + assert_eq!( + run.stream_chromatogram_provenance( + run.streams[0].id, + crate::ChromatogramKind::TotalIonCurrent + ), + Some(crate::ChromatogramProvenance::Source) + ); } #[test] @@ -327,65 +338,3 @@ fn skips_an_empty_scan_when_the_sample_has_readable_spectra() { assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("file=fixture scan=4")); } - -#[test] -fn local_wiff_fixture_imports_validated_multi_sample_layout_when_present() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join(".tmp/WIFF/20250305.wiff"); - if !path.is_file() { - return; - } - - let loaded = load(&path).expect("local legacy WIFF fixture should import every sample"); - assert!( - loaded.warnings.is_empty(), - "valid empty scan headers are not import warnings" - ); - assert_eq!( - loaded.format, - DataFormat::MassSpectrometry(MassSpectrometryFormat::SciexWiff) - ); - let Acquisition::MassSpec(run) = loaded.acquisition else { - panic!("WIFF should produce a mass-spectrometry run"); - }; - assert_eq!(run.metadata["sample count"].parse::().unwrap(), 2); - assert_eq!(run.metadata["samples"], "yjs_10ppm #1, yjs_10ppm #2"); - assert_eq!(run.streams.len(), 22); - assert_eq!(run.chromatograms.len(), 22); - let sample0: usize = run.streams[..11] - .iter() - .flat_map(|stream| &stream.spectra) - .filter(|spectrum| spectrum.tic > 0.0) - .count(); - let sample1: usize = run.streams[11..] - .iter() - .flat_map(|stream| &stream.spectra) - .filter(|spectrum| spectrum.tic > 0.0) - .count(); - assert_eq!((sample0, sample1), (3141, 3072)); - assert!( - run.streams - .iter() - .flat_map(|stream| &stream.spectra) - .all(|spectrum| { - spectrum.mz.len() == spectrum.intensity.len() - && spectrum.retention_time_min.is_finite() - && spectrum.mz.iter().all(|value| value.is_finite()) - }) - ); - let tic = &run.chromatograms[0]; - assert_eq!(tic.time_min.len(), 3905); - assert!(tic.time_min.windows(2).all(|pair| pair[1] > pair[0])); - assert!((tic.time_min[0] - 0.002533333333333333).abs() < 1e-9); - assert!((tic.time_min[3904] - 13.49435).abs() < 1e-9); - let ms1 = &run.streams[0]; - let peak = ms1 - .spectra - .iter() - .max_by(|left, right| left.tic.total_cmp(&right.tic)) - .unwrap(); - assert!((peak.retention_time_min - 0.9720333333333334).abs() < 1e-9); - assert_eq!(peak.tic, 5374726.0); - assert_eq!(loaded.provenance.companion_paths.len(), 1); -} diff --git a/crates/io/src/sciex_wiff_tic.rs b/crates/io/src/sciex_wiff_tic.rs index 3da035e..3ce14b2 100644 --- a/crates/io/src/sciex_wiff_tic.rs +++ b/crates/io/src/sciex_wiff_tic.rs @@ -1,5 +1,6 @@ use crate::{ - AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, IoError, + AcquisitionStream, ChromatogramChannel, ChromatogramChannelId, ChromatogramKind, + ChromatogramProvenance, IoError, }; pub(super) fn channels( @@ -50,6 +51,7 @@ pub(super) fn channels( Ok(ChromatogramChannel { id: ChromatogramChannelId(format!("{prefix}{local}")), kind: ChromatogramKind::TotalIonCurrent, + provenance: ChromatogramProvenance::Source, polarity: source.map_or(crate::Polarity::Unknown, AcquisitionStream::polarity), transition: None, source_stream: source.map(|s| s.id), diff --git a/crates/io/src/waters.rs b/crates/io/src/waters.rs index 0c83429..7e7ae24 100644 --- a/crates/io/src/waters.rs +++ b/crates/io/src/waters.rs @@ -2,9 +2,10 @@ use crate::{ Acquisition, AcquisitionStream, AcquisitionStreamId, ChromatogramChannel, - ChromatogramChannelId, ChromatogramKind, DataFormat, IoError, LoadResult, LoadWarning, - LoadWarningCode, MassSpecRun, MassSpectrometryFormat, MassSpectrum, Polarity, Provenance, - SpectrumAcquisition, SpectrumId, SpectrumRepresentation, SpectrumSummaryProvenance, StreamRole, + ChromatogramChannelId, ChromatogramKind, ChromatogramProvenance, DataFormat, IoError, + LoadResult, LoadWarning, LoadWarningCode, MassSpecRun, MassSpectrometryFormat, MassSpectrum, + Polarity, Provenance, SpectrumAcquisition, SpectrumId, SpectrumRepresentation, + SpectrumSummaryProvenance, StreamRole, }; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; @@ -224,7 +225,6 @@ pub fn load(path: &Path) -> Result { }) { return Err(invalid("the bundle contains no readable MS function")); } - let mut chromatograms = optical_channels(&functions)?; let streams = functions .into_iter() @@ -622,6 +622,7 @@ fn optical_channels(functions: &[DecodedFunction]) -> Result(), - expected_pairs - ); - let coordinates = run - .chromatograms - .iter() - .filter(|channel| channel.kind == ChromatogramKind::Optical) - .filter_map(|channel| channel.coordinate) - .collect::>(); - assert_eq!(coordinates, [214.0, 254.0]); - } -} diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index 93b4019..e1f55bc 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -80,6 +80,16 @@ 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. +TIC and BPC provenance is kept separate. A file-supplied TIC or BPC channel is +marked as a source chromatogram and is used for its bound acquisition stream +before any fallback. When that channel is absent, PlotX uses the per-spectrum +source summary when available; otherwise it deterministically derives TIC from +the intensity array or BPC from the largest non-negative peak. A mixed run can +therefore report both source summaries and array-derived points. An unbound +source chromatogram in a multi-stream run is kept as its own channel rather +than guessed as a replacement for a stream's TIC or BPC. Field metadata, +Scientific Summary, and Scientific Script expose the selected provenance. + For runs with many chromatogram channels, open **Dataset tools → Mass spectrometry** from **Extract Mass Spectrum** or the command palette. The **Chromatogram channels** browser lists a stable count and ordering, with TIC 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 783251d..053c517 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -67,6 +67,14 @@ DIA 没有 selected ion,其隔离窗目标也不会被误写成 selected-ion m 指出对应谱图,并说明只保留了第一个值。Scientific Script 的 scan 快照会暴露摘要 provenance、instrument configuration、源 event 或 preset,以及 filter string。 +TIC 与 BPC 的 provenance 分开保存。文件直接提供的 TIC 或 BPC channel 会标记为 +source chromatogram,并在其绑定的 acquisition stream 中优先使用。缺少绑定 channel +时,PlotX 会先使用每张谱图的 source summary;只有没有 source summary 时,才确定性地 +从强度数组派生 TIC,或从最大的非负峰派生 BPC。因此同一次采集中可能同时存在 source +summary 与数组派生点。多 stream 运行中未绑定的 source chromatogram 会作为独立 channel +保留,不会被猜测成某个 stream 的 TIC 或 BPC,也不会让 TIC channel 代替 BPC。field +metadata、Scientific Summary 和 Scientific Script 都会暴露最终选用的 provenance。 + 对于包含大量色谱 channel 的数据,请通过 **Extract Mass Spectrum** 或命令面板打开 **Dataset tools → Mass spectrometry**。**Chromatogram channels** 浏览器会显示稳定的 数量和顺序,其中 TIC、BPC 排在 SIM/SRM transition 之前。自由文本可匹配 channel