RSpin is a Rust library workspace for nuclear magnetic resonance data workflows.
The first implementation target is a pure-Rust library stack for IO, processing, analysis, simulation, and prediction abstractions. A GUI is intentionally out of scope for now.
rspin: facade crate.rspin-core: shared model, units, metadata, and errors.rspin-io: spectrum readers and writers.rspin-processing: signal-processing operations.rspin-analysis: peak, range, integral, zone, and assignment logic.rspin-simulation: synthetic spectrum generation.rspin-prediction: prediction traits and adapter types.rspin-wasm: WebAssembly bindings.
Use the facade crate for normal loading. load_spectra accepts one supported
file or directory, while load_spectra_relative_to resolves one selected path
below a base directory and reports stable relative source paths.
load_spectra_many merges selected files and directories into one
SpectrumBundle. Use load_spectra_many_relative_to when multiple selected
paths should share one base directory.
use rspin::prelude::*;
fn load_one_dataset() -> Result<SpectrumBundle> {
load_spectra("data/experiment")
}
fn load_first_1d_point_count() -> Result<Option<usize>> {
let bundle = load_spectra("data/experiment")?;
Ok(bundle.first_1d().map(|spectrum| spectrum.len()))
}
fn load_all_1d_spectra() -> Result<SpectrumBundle> {
load_spectra_1d("data/experiment")
}
fn load_all_1d_spectra_strict() -> Result<SpectrumBundle> {
load_spectra_1d_strict("data/experiment")
}
fn summarize_dataset() -> Result<SpectrumBundleSummary> {
load_spectra_summary("data/experiment")
}
fn load_first_bruker_2d_shape() -> Result<Option<(usize, usize)>> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle
.first_2d_by_source(LoadedSourceFilter::vendor("bruker"))
.map(|spectrum| spectrum.shape()))
}
fn load_all_2d_spectra() -> Result<SpectrumBundle> {
load_spectra_2d("data/mixed-vendor")
}
fn load_one_dataset_with_stable_sources() -> Result<SpectrumBundle> {
load_spectra_relative_to("data", "experiment")
}
fn load_selected_inputs() -> Result<SpectrumBundle> {
load_spectra_many([
"data/proton.fid",
"data/carbon.jdf",
"data/bruker/pdata/1",
])
}
fn load_selected_inputs_with_stable_sources() -> Result<SpectrumBundle> {
load_spectra_many_relative_to("data", ["proton.fid", "carbon.jdf", "bruker/pdata/1"])
}
fn load_selected_1d_inputs_with_stable_sources() -> Result<SpectrumBundle> {
load_spectra_1d_many_relative_to("data", ["proton.fid", "carbon.jdf", "bruker/pdata/1"])
}
fn summarize_selected_inputs() -> Result<SpectrumBundleSummary> {
load_spectra_many_summary_relative_to("data", ["proton.fid", "carbon.jdf", "bruker/pdata/1"])
}
fn load_selected_bruker_inputs() -> Result<SpectrumBundle> {
load_spectra_many_by_source_vendor(
["data/proton.fid", "data/bruker/pdata/1"],
LoadedSourceVendor::Bruker,
)
}
fn load_selected_with_runtime_filter() -> Result<SpectrumBundle> {
let filter = LoadedSourceFilter::vendor("bruker");
load_spectra_many_by_source(["data/proton.fid", "data/bruker/pdata/1"], filter)
}
fn load_selected_with_runtime_filters() -> Result<SpectrumBundle> {
load_spectra_by_sources(
"data/mixed-vendor",
[
LoadedSourceFilter::vendor("bruker"),
LoadedSourceFilter::path("jcamp/carbon_13c.jdx"),
],
)
}
fn load_one_with_runtime_filter() -> Result<Spectrum1D> {
load_spectrum_1d_by_source("data/mixed-vendor", LoadedSourceFilter::vendor("bruker"))
}
fn load_one_with_runtime_filters() -> Result<Spectrum1D> {
load_spectrum_1d_by_sources(
"data/mixed-vendor",
[
LoadedSourceFilter::vendor("bruker"),
LoadedSourceFilter::path("jcamp/carbon_13c.jdx"),
],
)
}
fn load_one_bruker_spectrum_from_selected_inputs() -> Result<Spectrum1D> {
load_spectrum_1d_many_by_source_vendor(
["data/proton.fid", "data/bruker/pdata/1"],
LoadedSourceVendor::Bruker,
)
}
fn load_exactly_one_selected_spectrum() -> Result<Spectrum1D> {
load_spectrum_1d_relative_to("data", "proton.fid")
}
fn load_exactly_one_bruker_spectrum() -> Result<Spectrum1D> {
load_spectrum_1d_by_source_vendor_relative_to(
"data",
"mixed-vendor",
LoadedSourceVendor::Bruker,
)
}
fn load_exactly_one_raw_spectrum() -> Result<Spectrum1D> {
load_spectrum_1d_by_source_data_kind("data/mixed-vendor", LoadedSourceDataKind::Raw)
}
fn load_exactly_one_raw_spectrum_from_selected_inputs() -> Result<Spectrum1D> {
load_spectrum_1d_many_by_source_data_kind(
["data/proton.fid", "data/bruker/pdata/1"],
LoadedSourceDataKind::Raw,
)
}
fn load_exactly_one_tracked_source() -> Result<Spectrum2D> {
load_spectrum_2d_by_source_path_relative_to(
"data",
"mixed-vendor",
"mixed-vendor/jeol/hsqc.jdf",
)
}
fn load_exactly_one_source_directory() -> Result<Spectrum1D> {
load_spectrum_1d_by_source_path_prefix("data/mixed-vendor", "bruker/pdata")
}
fn load_selected_source_directories() -> Result<SpectrumBundle> {
load_spectra_by_source_path_prefixes("data/mixed-vendor", ["jcamp", "jeol"])
}
fn load_exactly_one_from_selected_source_directories() -> Result<Spectrum2D> {
load_spectrum_2d_by_source_path_prefixes("data/mixed-vendor", ["jeol", "bruker/cosy"])
}
fn load_jcamp_bundle() -> Result<SpectrumBundle> {
load_spectra_by_source_format("data/mixed-vendor", LoadedSourceFormat::JcampDx)
}
fn load_open_format_bundle() -> Result<SpectrumBundle> {
load_spectra_by_source_formats(
"data/mixed-vendor",
[LoadedSourceFormat::JcampDx, LoadedSourceFormat::NmrMl],
)
}
fn load_bruker_bundle() -> Result<SpectrumBundle> {
load_spectra_by_source_vendor("data/mixed-vendor", LoadedSourceVendor::Bruker)
}
fn load_vendor_bundles() -> Result<SpectrumBundle> {
load_spectra_by_source_vendors(
"data/mixed-vendor",
[LoadedSourceVendor::Bruker, LoadedSourceVendor::Jeol],
)
}
fn load_with_reader_chain() -> Result<SpectrumBundle> {
RSpinReader::new()
.source_vendor("bruker")
.processed_sources()
.read_relative_to("data", "mixed-vendor")
}
fn load_one_source_directory() -> Result<SpectrumBundle> {
RSpinReader::new()
.source_path_prefix("mixed-vendor/bruker/pdata")
.read_relative_to("data", "mixed-vendor")
}
fn load_one_source_directory_short() -> Result<SpectrumBundle> {
load_spectra_by_source_path_prefix("data/mixed-vendor", "bruker/pdata")
}
fn load_raw_vendor_bundle() -> Result<SpectrumBundle> {
load_spectra_by_source_data_kind("data/mixed-vendor", LoadedSourceDataKind::Raw)
}
fn load_vendor_bundle() -> Result<SpectrumBundle> {
load_spectra_by_source_data_kinds(
"data/mixed-vendor",
[LoadedSourceDataKind::Raw, LoadedSourceDataKind::Processed],
)
}
fn load_one_tracked_source_as_bundle() -> Result<SpectrumBundle> {
load_spectra_by_source_path("data/mixed-vendor", "jcamp/carbon_13c.jdx")
}Bundles expose direct counts and owned extraction helpers for simple workflows:
use rspin::prelude::*;
fn inspect_bundle() -> Result<Vec<Spectrum1D>> {
let bundle = load_spectra("data/experiment")?;
let summary = bundle.summary();
println!("loaded {} 1D and {} 2D spectra", summary.spectra_1d(), summary.spectra_2d());
println!(
"loaded {} JCAMP-DX spectra",
summary.source_format_count(LoadedSourceFormat::JcampDx)
);
for count in &summary.source_formats {
println!("{}: {}", count.format(), count.count());
}
for count in &summary.source_vendors {
println!("{} vendor spectra: {}", count.vendor(), count.count());
}
for count in &summary.source_data_kinds {
println!("{} data spectra: {}", count.data_kind(), count.count());
}
for count in &summary.source_paths {
println!("{} source spectra: {}", count.path().display(), count.count());
}
for count in &summary.warning_paths {
println!("{} warning count: {}", count.path().display(), count.count());
}
println!(
"loaded {} raw vendor spectra",
summary.source_data_kind_count(LoadedSourceDataKind::Raw)
);
for (spectrum, source) in bundle.loaded_1d_by_source_format(LoadedSourceFormat::JcampDx) {
let label = source
.path()
.map_or_else(|| "<memory>".to_owned(), |path| path.display().to_string());
println!("{label} has {} points from {}", spectrum.len(), source.data_kind());
}
for path in bundle.source_paths_for_source(LoadedSourceFilter::vendor("bruker")) {
println!("Bruker source: {}", path.display());
}
for path in bundle.source_paths_for_path_prefix("bruker/pdata") {
println!("processed source: {}", path.display());
}
Ok(bundle.into_spectra_1d())
}
fn load_owned_vendor_subset() -> Result<Vec<Spectrum1D>> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.into_spectra_1d_by_source(LoadedSourceFilter::vendor("bruker")))
}
fn load_owned_runtime_subset() -> Result<Vec<Spectrum1D>> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.into_spectra_1d_by_sources([
LoadedSourceFilter::vendor("bruker"),
LoadedSourceFilter::path("jcamp/carbon_13c.jdx"),
]))
}
fn keep_runtime_subset_as_bundle() -> Result<SpectrumBundle> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.source_subset_by_sources([
LoadedSourceFilter::vendor("bruker"),
LoadedSourceFilter::path("jcamp/carbon_13c.jdx"),
]))
}
fn keep_bruker_subset_as_bundle() -> Result<SpectrumBundle> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.source_vendor_subset(LoadedSourceVendor::Bruker))
}
fn keep_processed_subset_as_bundle() -> Result<SpectrumBundle> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.processed_source_subset())
}
fn keep_one_source_directory_as_bundle() -> Result<SpectrumBundle> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.source_path_prefix_subset("bruker/pdata"))
}
fn keep_selected_source_directories_as_bundle() -> Result<SpectrumBundle> {
let bundle = load_spectra("data/mixed-vendor")?;
Ok(bundle.source_path_prefix_subset_by_prefixes(["jcamp", "jeol"]))
}
fn load_named_carbon_spectrum() -> Result<Spectrum1D> {
let bundle = load_spectra("data/sample")?;
let (spectrum, _) = bundle
.loaded_1d_by_source_path("carbon_13c")
.ok_or_else(|| RSpinError::Parse {
format: "spectrum bundle",
message: "missing carbon_13c".to_owned(),
})?;
Ok(spectrum.clone())
}RSpinReader exposes the same reader with chainable options:
use rspin::prelude::*;
fn load_processed_only() -> Result<SpectrumBundle> {
RSpinReader::new()
.processed_only()
.strict()
.read_path("data/bruker")
}
fn load_jcamp_only() -> Result<SpectrumBundle> {
RSpinReader::new()
.only_source_format(LoadedSourceFormat::JcampDx)
.read_path("data/mixed-vendor")
}
fn load_bruker_only() -> Result<SpectrumBundle> {
RSpinReader::new()
.only_source_vendor(LoadedSourceVendor::Bruker)
.read_path("data/mixed-vendor")
}
fn load_with_runtime_filter(filter: LoadedSourceFilter) -> Result<SpectrumBundle> {
RSpinReader::new()
.only_source(filter)
.read_path("data/mixed-vendor")
}
fn load_processed_vendor_data() -> Result<SpectrumBundle> {
RSpinReader::new()
.only_processed_sources()
.read_path("data/mixed-vendor")
}
fn summarize_raw_vendor_data() -> Result<SpectrumBundleSummary> {
RSpinReader::new()
.only_raw_sources()
.read_summary("data/mixed-vendor")
}
fn load_bruker_2d_bundle() -> Result<SpectrumBundle> {
RSpinReader::new()
.source_vendor(LoadedSourceVendor::Bruker)
.read_bundle_2d("data/mixed-vendor")
}
fn load_one_tracked_path() -> Result<SpectrumBundle> {
RSpinReader::new()
.only_source_path("jcamp/carbon_13c.jdx")
.read_path("data/mixed-vendor")
}Use the type-safe discovery helpers when building format, vendor, or data-kind selectors:
use rspin::prelude::*;
fn supported_vendor_filters() -> Vec<&'static str> {
LoadedSourceVendor::all()
.iter()
.map(|vendor| vendor.as_str())
.collect()
}
fn supported_format_filters() -> Vec<&'static str> {
LoadedSourceFormat::all()
.iter()
.map(|format| format.as_str())
.collect()
}
fn supported_data_kind_filters() -> Vec<&'static str> {
LoadedSourceDataKind::all()
.iter()
.map(|data_kind| data_kind.as_str())
.collect()
}
fn supported_standalone_extensions() -> Vec<&'static str> {
LoadedSourceFormat::all()
.iter()
.flat_map(|format| format.file_extensions())
.copied()
.collect()
}
fn preflight_sources() -> Result<Vec<DiscoveredSpectrumSource>> {
RSpinReader::new()
.source_vendor("bruker")
.processed_sources()
.discover("data/mixed-vendor")
}
fn summarize_preflight() -> Result<DiscoveredSpectrumSummary> {
let sources = discover_spectra("data/mixed-vendor")?;
let summary = summarize_discovered_spectra(&sources);
assert!(summary.has_source_path_prefix("bruker"));
Ok(summary)
}
fn count_runtime_selection() -> Result<usize> {
let sources = discover_spectra("data/mixed-vendor")?;
let selected = select_discovered_spectra_by_sources(
&sources,
[
LoadedSourceFilter::vendor("bruker"),
LoadedSourceFilter::path_prefix("jcamp"),
],
);
Ok(selected.len())
}
fn load_first_selected_source() -> Result<SpectrumBundle> {
let sources = discover_spectra("data/mixed-vendor")?;
let selected = select_discovered_spectra_by_source(
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
);
let source = selected.first().ok_or_else(|| RSpinError::Parse {
format: "spectrum source discovery",
message: "no matching source found".to_owned(),
})?;
source.load_relative_to("data/mixed-vendor")
}
fn load_first_selected_spectrum() -> Result<Spectrum1D> {
let sources = discover_spectra("data/mixed-vendor")?;
let selected = select_discovered_spectra_by_source(
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
);
let source = selected.first().ok_or_else(|| RSpinError::Parse {
format: "spectrum source discovery",
message: "no matching source found".to_owned(),
})?;
source.load_1d_relative_to("data/mixed-vendor")
}
fn load_first_selected_spectrum_strict() -> Result<Spectrum1D> {
let sources = discover_spectra("data/mixed-vendor")?;
let selected = select_discovered_spectra_by_source(
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
);
let source = selected.first().ok_or_else(|| RSpinError::Parse {
format: "spectrum source discovery",
message: "no matching source found".to_owned(),
})?;
source.load_1d_strict_relative_to("data/mixed-vendor")
}
fn load_selected_spectrum_with_reader() -> Result<Spectrum1D> {
let sources = discover_spectra("data/mixed-vendor")?;
let selected = select_discovered_spectra_by_source(
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
);
RSpinReader::new()
.strict()
.read_discovered_1d_relative_to("data/mixed-vendor", selected)
}
fn load_selected_spectrum_with_filter() -> Result<Spectrum1D> {
let sources = discover_spectra("data/mixed-vendor")?;
RSpinReader::new()
.strict()
.read_discovered_1d_by_source_relative_to(
"data/mixed-vendor",
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
)
}
fn load_selected_spectrum_with_free_helper() -> Result<Spectrum1D> {
let sources = discover_spectra("data/mixed-vendor")?;
load_discovered_spectrum_1d_by_source_relative_to(
"data/mixed-vendor",
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
)
}
fn load_selected_bundle_strict_with_free_helper() -> Result<SpectrumBundle> {
let sources = discover_spectra("data/mixed-vendor")?;
load_discovered_spectra_strict_by_source_relative_to(
"data/mixed-vendor",
&sources,
LoadedSourceFilter::path_prefix("bruker/pdata"),
)
}
fn load_selected_1d_bundle_after_preflight() -> Result<SpectrumBundle> {
let sources = discover_spectra("data/mixed-vendor")?;
load_discovered_spectra_1d_by_source_relative_to(
"data/mixed-vendor",
&sources,
LoadedSourceFilter::vendor("jeol"),
)
}
fn summarize_selected_after_preflight() -> Result<SpectrumBundleSummary> {
let sources = discover_spectra("data/mixed-vendor")?;
load_discovered_spectra_summary_by_source_relative_to(
"data/mixed-vendor",
&sources,
LoadedSourceFilter::processed(),
)
}
fn load_selected_after_preflight() -> Result<SpectrumBundle> {
let sources = RSpinReader::new()
.processed_sources()
.discover("data/mixed-vendor")?;
load_discovered_spectra_by_source_relative_to(
"data/mixed-vendor",
&sources,
LoadedSourceFilter::vendor("bruker"),
)
}The unified loader currently routes supported Bruker, Agilent/Varian, JEOL,
JCAMP-DX, nmrML, NMReDATA, JSON, and CSV inputs without replacing the
format-specific readers. Browser callers should parse uploaded bytes with the
format-specific WASM helpers, then use createSpectrumBundle to assemble the
same versioned bundle JSON used by native code. Use
spectrumBundleSourceFormats, spectrumBundleSourceVendors, and
spectrumBundleSourceDataKinds to populate browser format, extension, vendor,
data-kind, and directory-marker selectors from the same source list as native
Rust. Use spectrumBundle1dBySource, spectrumBundle1dBySources, or the
corresponding 2D helpers when browser code needs to extract exactly one
dimension by runtime source-filter JSON. Typed helpers such as
spectrumBundle1dBySourceDataKind remain available for common raw, processed,
or other source data-kind selections.
The small committed loader fixtures under
crates/rspin-io/testdata/zenodo_7100132 come from the MIT-licensed Zenodo
software record https://doi.org/10.5281/zenodo.7100132; see the fixture
README for file-level provenance and checksums.
The committed public parser fixtures under crates/rspin-io/testdata/nmrxiv
come from CC0 and CC-BY-4.0 NMRXiv studies; see the fixture README for source
DOIs, authors, license URLs, included-file provenance, changes, and checksums.
Other public datasets should only be committed when redistribution is permitted
and documented next to the files. The top-level
crates/rspin-io/testdata/README.md records the fixture policy.
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features- Processing and analysis PNG examples:
docs/examples/processed-data.md - Local visual artifacts from raw oracle fixtures:
target/rspin-visual-testsafter running the feature-gatedruvizexample. - Public analysis fixture sources and workflow snippets:
docs/examples/public-analysis-fixtures.md
external-baselines: enables the optional crates.iobaselinescrate for additional baseline correction methods. It is off by default so the core library dependency graph stays small and reviewable.visualization-ruviz: enables the standalone processed-data PNG example inexamples/ruviz-processing. It is not part of the main workspace defaults.
Licensed under either of:
- Apache License, Version 2.0
- MIT license
at your option.