Skip to content

Repository files navigation

RSpin

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.

Workspace

  • 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.

Unified IO

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.

Development

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features

Examples

  • Processing and analysis PNG examples: docs/examples/processed-data.md
  • Local visual artifacts from raw oracle fixtures: target/rspin-visual-tests after running the feature-gated ruviz example.
  • Public analysis fixture sources and workflow snippets: docs/examples/public-analysis-fixtures.md

Optional Features

  • external-baselines: enables the optional crates.io baselines crate 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 in examples/ruviz-processing. It is not part of the main workspace defaults.

License

Licensed under either of:

  • Apache License, Version 2.0
  • MIT license

at your option.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages