Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ preparation.
## Highlights

- **Bring scientific data together.** Current import support includes Axon
ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML and Waters
MassLynx LC–MS runs, JEOL Delta, Bruker TopSpin, and Varian/Agilent VnmrJ
experiments, JCAMP-DX spectra, archives, and delimited tables.
ABF2 patch-clamp recordings, Rigaku powder XRD patterns, mzML, Waters
MassLynx, and legacy SCIEX WIFF LC–MS runs, JEOL Delta, Bruker TopSpin,
and Varian/Agilent VnmrJ experiments, JCAMP-DX spectra, archives, and
delimited tables.
- **Process and analyze interactively.** Build ordered processing pipelines,
then pick peaks, integrate regions, and fit data. NMR workflows also include
DOSY and relaxation analysis, plus sweep statistics and IV analysis for
Expand Down
5 changes: 3 additions & 2 deletions crates/app/src/ui/file_dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) {
&["png", "jpg", "jpeg", "tif", "tiff", "webp", "bmp"],
)
.add_filter(
"All supported data (*.mzML, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)",
"All supported data (*.mzML, *.wiff, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)",
origin::OPEN_FILE_FILTER_EXTENSIONS,
)
.add_filter("Rigaku XRD (*.rasx, *.raw, *.txt)", &["rasx", "raw", "txt"])
Expand All @@ -391,6 +391,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) {
.add_filter("Axon Binary Format 2 (*.abf)", &["abf"])
.add_filter("JEOL Delta (*.jdf)", &["jdf"])
.add_filter("mzML mass spectrometry (*.mzML)", &["mzML"])
.add_filter("SCIEX legacy WIFF (*.wiff)", &["wiff"])
.add_filter("XPS (*.vms, CasaXPS *.txt)", &["vms", "txt"])
.add_filter("Bruker TopSpin (fid, ser)", &["fid", "ser"])
.add_filter("Varian/Agilent VnmrJ (fid)", &["fid"])
Expand Down Expand Up @@ -427,7 +428,7 @@ pub(crate) fn choose_project_save_path() -> Option<std::path::PathBuf> {

pub(crate) fn open_folder(app: &mut PlotxApp) {
if let Some(path) = rfd::FileDialog::new()
.set_title("Open a data folder (Waters MassLynx RAW, Bruker, Varian/Agilent VnmrJ, or recursive AFM/ABF2 import)")
.set_title("Open a data folder (vendor acquisitions or recursive scientific-data import)")
.pick_folder()
{
open_folder_path(app, &path);
Expand Down
20 changes: 19 additions & 1 deletion crates/app/src/ui/file_dialogs/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub(super) fn collect_data_files(folder: &Path, output: &mut Vec<PathBuf>) {
.extension()
.and_then(|value| value.to_str())
.unwrap_or("");
let supported_extension = ["abf", "spm", "pfc", "rasx", "vms"]
let supported_extension = ["abf", "spm", "pfc", "rasx", "vms", "wiff"]
.iter()
.any(|supported| extension.eq_ignore_ascii_case(supported));
let recognized_raw =
Expand Down Expand Up @@ -92,4 +92,22 @@ mod tests {
assert_eq!(found, vec![dataset]);
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn folder_scan_discovers_only_the_primary_wiff_file() {
let root =
std::env::temp_dir().join(format!("plotx-wiff-discovery-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let wiff = root.join("sample.WIFF");
std::fs::write(&wiff, b"container").unwrap();
std::fs::write(root.join("sample.WIFF.scan"), b"scans").unwrap();
std::fs::write(root.join("sample.wiff2"), b"wiff2").unwrap();
std::fs::write(root.join("sample.timeseries.data"), b"data").unwrap();

let mut found = Vec::new();
collect_data_files(&root, &mut found);

assert_eq!(found, vec![wiff]);
std::fs::remove_dir_all(root).unwrap();
}
}
3 changes: 2 additions & 1 deletion crates/app/src/ui/file_dialogs/origin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ pub(super) const ORIGIN_PROJECT_FILTER_LABEL: &str =
"Origin projects (experimental: OPJ import; OPJU recognition only)";
pub(super) const ORIGIN_PROJECT_FILTER_EXTENSIONS: &[&str] = &["opj", "opju"];
pub(super) const OPEN_FILE_FILTER_EXTENSIONS: &[&str] = &[
"mzML", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip", "opj",
"mzML", "wiff", "rasx", "raw", "vms", "txt", "spm", "pfc", "abf", "jdf", "fid", "ser", "zip",
"opj",
];

const ORIGIN_MEDIA_TYPE: &str = "application/x-origin-project";
Expand Down
1 change: 1 addition & 0 deletions crates/app/src/ui/file_dialogs/origin_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ fn origin_import_filter_retains_tables_and_adds_experimental_projects() {
fn origin_supported_file_filter_excludes_recognition_only_opju() {
assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"opj"));
assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"mzML"));
assert!(OPEN_FILE_FILTER_EXTENSIONS.contains(&"wiff"));
assert!(!OPEN_FILE_FILTER_EXTENSIONS.contains(&"opju"));
}

Expand Down
48 changes: 48 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,17 @@ fn text_report(report: &InspectionReport) -> String {
ephys.protocol.as_deref().unwrap_or("unknown")
));
}
if let Some(mass_spec) = &report.mass_spectrometry {
if let Some(instrument) = &mass_spec.instrument {
lines.push(format!("mass_spec.instrument: {instrument}"));
}
lines.push(format!("mass_spec.streams: {}", mass_spec.stream_count));
lines.push(format!("mass_spec.scans: {}", mass_spec.ms_scan_count));
lines.push(format!(
"mass_spec.chromatograms: {}",
mass_spec.chromatograms.join(", ")
));
}
if let Some(xrd) = &report.xrd {
lines.push(format!("xrd.points: {}", xrd.point_count));
lines.push(format!(
Expand Down Expand Up @@ -728,6 +739,43 @@ mod tests {
assert!(parse(&["plotx-cli", "batch", "workflow.json"]).is_err());
}

#[test]
fn text_inspection_includes_mass_spectrometry_statistics() {
let report = InspectionReport {
schema: plotx_core::workflow::INSPECTION_SCHEMA,
format: "sciex-wiff".to_owned(),
provenance: plotx_core::workflow::ProvenanceReport {
selected_path: "sample.wiff".into(),
data_path: "sample.wiff".into(),
parameter_paths: Vec::new(),
companion_paths: vec!["sample.wiff.scan".into()],
},
dimension: plotx_core::workflow::DimensionReport {
count: 3,
shape: vec![2, 42, 1],
},
domain: "mass_spectrometry".to_owned(),
warnings: Vec::new(),
electrophysiology: None,
afm: None,
mass_spectrometry: Some(plotx_core::workflow::MassSpecReport {
instrument: Some("SCIEX TripleTOF 6600".to_owned()),
stream_count: 2,
ms_scan_count: 42,
chromatograms: vec!["total ion current chromatogram".to_owned()],
}),
xrd: None,
xps: None,
};

let output = text_report(&report);

assert!(output.contains("format: sciex-wiff"));
assert!(output.contains("mass_spec.streams: 2"));
assert!(output.contains("mass_spec.scans: 42"));
assert!(output.contains("mass_spec.chromatograms: total ion current chromatogram"));
}

#[test]
fn workflow_errors_map_to_stable_exit_categories() {
let status = fail(WorkflowError::FigureUnavailable("NMR 1D"));
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/state/dataset_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ impl Dataset {
Self::Afm(_) => None,
Self::MassSpec(data) => {
let stream = data.run.stream(data.active_stream)?;
let chromatogram =
super::mass_spec_tic::points_for_stream_tic(&data.run, data.active_stream);
if let Some(points) = chromatogram {
let (xs, ys): (Vec<_>, Vec<_>) = points
.into_iter()
.map(|[time, value]| (time, value))
.unzip();
return Some(Trace1d {
xs,
ys,
x_reversed: false,
});
}
Some(Trace1d {
xs: stream
.spectra
Expand Down
14 changes: 9 additions & 5 deletions crates/core/src/state/mass_spec.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::mass_spec_tic::points_for_stream_tic;
use super::{
DatasetId, DatasetLineage, FieldCatalog, FieldId,
mass_spec_xic::{ExtractedIonChromatogram, IonChromatogramId, xic_key, xic_title},
Expand Down Expand Up @@ -460,15 +461,18 @@ impl MassSpecDataset {
let stream_id = stream.id;
let stream_label = stream_display_label(stream);
if self.field_catalog.id_for_key(&stream_tic_key(stream_id)) == Some(id) {
let chromatogram_points = points_for_stream_tic(&self.run, stream_id);
return Some((
format!("{stream_label} TIC"),
"Retention time (min)",
"Total ion current".to_owned(),
stream
.spectra
.iter()
.map(|scan| [scan.retention_time_min, scan.tic])
.collect(),
chromatogram_points.unwrap_or_else(|| {
stream
.spectra
.iter()
.map(|scan| [scan.retention_time_min, scan.tic])
.collect()
}),
false,
));
}
Expand Down
43 changes: 43 additions & 0 deletions crates/core/src/state/mass_spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::actions::Action;
use crate::state::{
AxisRange, Dataset, ObjectFrame, PlotxApp, SeriesBinding, SeriesSource, ToolGroup,
};
use plotx_io::{ChromatogramChannel, ChromatogramChannelId};

#[test]
fn dynamic_catalog_and_stable_selection_follow_stream_identity() {
Expand Down Expand Up @@ -86,6 +87,48 @@ fn mean_extraction_averages_missing_profile_coordinates_as_zero() {
assert_eq!(points, [[10.0, 1.0], [20.0, 4.5], [30.0, 0.5]]);
}

#[test]
fn stream_tic_prefers_bound_chromatogram_points() {
let mut run = sample_mass_spec_run();
run.chromatograms.push(ChromatogramChannel {
id: ChromatogramChannelId("tic:bound".to_owned()),
kind: ChromatogramKind::Unknown,
source_stream: Some(AcquisitionStreamId::new(3)),
coordinate: None,
description: "Total ion current".to_owned(),
unit: "cps".to_owned(),
time_min: vec![0.0, 2.0],
values: vec![11.0, 22.0],
});
let dataset = MassSpecDataset::load(run);
let field = dataset
.field_catalog
.id_for_key(&stream_tic_key(AcquisitionStreamId::new(3)))
.expect("stream TIC field");
let (_, _, _, points, stick) = dataset.field_values(field).expect("TIC values");
assert!(!stick);
assert_eq!(points, [[0.0, 11.0], [2.0, 22.0]]);
}

#[test]
fn displayed_mass_spec_trace_uses_bound_tic_channel() {
let mut run = sample_mass_spec_run();
run.chromatograms.push(ChromatogramChannel {
id: ChromatogramChannelId("tic:rendered".to_owned()),
kind: ChromatogramKind::Unknown,
source_stream: Some(AcquisitionStreamId::new(3)),
coordinate: None,
description: "Total ion current".to_owned(),
unit: "cps".to_owned(),
time_min: vec![0.0, 1.0],
values: vec![100.0, 200.0],
});
let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(run)));
let trace = dataset.displayed_trace(None).expect("mass-spec trace");
assert_eq!(trace.xs, [0.0, 1.0]);
assert_eq!(trace.ys, [100.0, 200.0]);
}

#[test]
fn stream_and_retention_time_selection_retarget_all_linked_plots() {
let dataset = Dataset::MassSpec(Box::new(MassSpecDataset::load(sample_mass_spec_run())));
Expand Down
23 changes: 23 additions & 0 deletions crates/core/src/state/mass_spec_tic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use plotx_io::{AcquisitionStreamId, MassSpecRun};

pub(crate) fn points_for_stream_tic(
run: &MassSpecRun,
stream_id: AcquisitionStreamId,
) -> Option<Vec<[f64; 2]>> {
let channel = run
.chromatograms
.iter()
.find(|channel| channel.source_stream == Some(stream_id))?;
if channel.time_min.len() != channel.values.len() {
return None;
}
Some(
channel
.time_min
.iter()
.copied()
.zip(channel.values.iter().copied())
.map(|(time, value)| [time, value])
.collect(),
)
}
1 change: 1 addition & 0 deletions crates/core/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ mod linefit;
mod mass_spec;
mod mass_spec_app;
mod mass_spec_ranges;
mod mass_spec_tic;
mod mass_spec_xic;
mod multiplet;
mod nmr_integrals;
Expand Down
2 changes: 2 additions & 0 deletions crates/io/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ tiff.workspace = true
sha2.workspace = true
memmap2.workspace = true
tempfile.workspace = true
cfb = "0.14"
byteorder = "1.5"
2 changes: 2 additions & 0 deletions crates/io/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub enum AfmFormat {
pub enum MassSpectrometryFormat {
WatersMassLynxRaw,
MzMl,
SciexWiff,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -87,6 +88,7 @@ impl DataFormat {
"waters-masslynx-raw"
}
Self::MassSpectrometry(MassSpectrometryFormat::MzMl) => "mzml",
Self::MassSpectrometry(MassSpectrometryFormat::SciexWiff) => "sciex-wiff",
Self::Xrd(XrdFormat::RigakuRasx) => "rigaku-rasx",
Self::Xrd(XrdFormat::RigakuRaw) => "rigaku-raw-fi",
Self::Xrd(XrdFormat::RigakuProfile) => "rigaku-profile",
Expand Down
Loading
Loading