From 053a9841b48d19d4632cc41fd94991447987582a Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 19 Aug 2026 17:22:31 +0800 Subject: [PATCH] feat(io): support Varian VnmrJ raw data --- README.md | 4 +- crates/app/src/ui/file_dialogs.rs | 3 +- crates/app/src/ui/file_dialogs/discovery.rs | 26 +- crates/core/src/state/app_impl_io.rs | 4 +- crates/io/src/archive.rs | 9 +- crates/io/src/lib.rs | 15 +- crates/io/src/varian.rs | 267 +++++++++++++++++ crates/io/src/varian/fid.rs | 200 +++++++++++++ crates/io/src/varian/procpar.rs | 187 ++++++++++++ crates/io/src/varian/tests.rs | 279 ++++++++++++++++++ .../src/content/docs/guides/importing-data.md | 15 +- .../content/docs/reference/file-formats.md | 14 + .../docs/zh-cn/guides/importing-data.md | 12 +- .../docs/zh-cn/reference/file-formats.md | 11 + 14 files changed, 1032 insertions(+), 14 deletions(-) create mode 100644 crates/io/src/varian.rs create mode 100644 crates/io/src/varian/fid.rs create mode 100644 crates/io/src/varian/procpar.rs create mode 100644 crates/io/src/varian/tests.rs diff --git a/README.md b/README.md index 258beaf..72d28ec 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ preparation. - **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 and Bruker TopSpin experiments, JCAMP-DX - spectra, archives, and delimited tables. + MassLynx 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 diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 3af3d02..b26509f 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -393,6 +393,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { .add_filter("mzML mass spectrometry (*.mzML)", &["mzML"]) .add_filter("XPS (*.vms, CasaXPS *.txt)", &["vms", "txt"]) .add_filter("Bruker TopSpin (fid, ser)", &["fid", "ser"]) + .add_filter("Varian/Agilent VnmrJ (fid)", &["fid"]) .add_filter("Archive (*.zip)", &["zip"]) .add_filter("All files", &["*"]) .set_title("Open data or add images — format is detected automatically") @@ -426,7 +427,7 @@ pub(crate) fn choose_project_save_path() -> Option { 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, or recursive AFM/ABF2 import)") + .set_title("Open a data folder (Waters MassLynx RAW, Bruker, Varian/Agilent VnmrJ, or recursive AFM/ABF2 import)") .pick_folder() { open_folder_path(app, &path); diff --git a/crates/app/src/ui/file_dialogs/discovery.rs b/crates/app/src/ui/file_dialogs/discovery.rs index a8bf720..9ef3ddf 100644 --- a/crates/app/src/ui/file_dialogs/discovery.rs +++ b/crates/app/src/ui/file_dialogs/discovery.rs @@ -1,9 +1,13 @@ use std::path::{Path, PathBuf}; pub(super) fn collect_data_files(folder: &Path, output: &mut Vec) { - // A MassLynx `.raw` directory is one atomic acquisition. Its numbered - // payload files must never be rediscovered as independent datasets. - if plotx_io::waters::is_masslynx_raw(folder) { + // Vendor acquisition directories are atomic. Their payload files must + // never be rediscovered as independent datasets. + if plotx_io::waters::is_masslynx_raw(folder) + || plotx_io::bruker::detect_processed(folder).is_some() + || plotx_io::bruker::is_bruker_dir(folder) + || plotx_io::varian::is_varian(folder) + { output.push(folder.to_owned()); return; } @@ -72,4 +76,20 @@ mod tests { assert_eq!(found, vec![xrd]); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn varian_directory_is_atomic() { + let root = + std::env::temp_dir().join(format!("plotx-varian-discovery-{}", uuid::Uuid::new_v4())); + let dataset = root.join("sample.fid"); + std::fs::create_dir_all(&dataset).unwrap(); + std::fs::write(dataset.join("procpar"), b"sw 1 1\n1 1000\n0\n").unwrap(); + std::fs::write(dataset.join("fid"), [0; 32]).unwrap(); + + let mut found = Vec::new(); + collect_data_files(&root, &mut found); + + assert_eq!(found, vec![dataset]); + std::fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index ebcea04..965748d 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -274,8 +274,8 @@ impl PlotxApp { } } - /// Open a `.zip` archive as a batch: extract it and load every JEOL `.jdf` - /// file and Bruker acquisition folder inside, each as its own dataset and + /// Open a `.zip` archive as a batch: extract it and load every supported + /// loose file and atomic acquisition folder inside, each as its own dataset and /// canvas. pub fn load_archive_from(&mut self, path: &std::path::Path) { let archive = Self::short_name(&path.to_string_lossy()); diff --git a/crates/io/src/archive.rs b/crates/io/src/archive.rs index a6c6cda..1a403bd 100644 --- a/crates/io/src/archive.rs +++ b/crates/io/src/archive.rs @@ -1,5 +1,5 @@ //! Batch loading from a `.zip` archive: extract to a scratch directory, then -//! walk the tree loading every supported loose spectrum and Bruker acquisition +//! walk the tree loading every supported loose spectrum and acquisition //! folder. use crate::{IoError, LoadResult, LoadWarning, LoadWarningCode}; @@ -72,11 +72,14 @@ fn scratch_dir() -> PathBuf { )) } -// Depth-first walk appending each loadable dataset. A Bruker acquisition folder +// Depth-first walk appending each loadable dataset. An acquisition folder // is loaded as a unit and not descended into; any other directory is recursed; // loose JEOL and JCAMP-DX files are read individually. fn collect_acquisitions(dir: &Path, out: &mut ArchiveLoadResult) { - if crate::bruker::detect_processed(dir).is_some() || crate::bruker::is_bruker_dir(dir) { + if crate::bruker::detect_processed(dir).is_some() + || crate::bruker::is_bruker_dir(dir) + || crate::varian::is_varian(dir) + { match crate::load_path(dir) { Ok(result) => out.items.push(result), Err(error) => out.warnings.push(entry_warning(dir, error)), diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 10d1ae7..9bfc6f8 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -10,6 +10,7 @@ mod mass_spec; pub mod mzml; pub mod nanoscope; pub mod origin; +pub mod varian; pub mod waters; pub mod xlsx; pub mod xps; @@ -27,6 +28,7 @@ pub enum DataFormat { Abf2, JeolDelta, BrukerRaw, + VarianAgilentRaw, BrukerProcessed1D, BrukerProcessed2D, JcampDx1D, @@ -47,6 +49,7 @@ impl DataFormat { Self::Abf2 => "abf2", Self::JeolDelta => "jeol-delta", Self::BrukerRaw => "bruker-raw", + Self::VarianAgilentRaw => "varian-agilent-raw", Self::BrukerProcessed1D => "bruker-processed-1d", Self::BrukerProcessed2D => "bruker-processed-2d", Self::JcampDx1D => "jcamp-dx-1d", @@ -615,6 +618,12 @@ pub enum IoError { #[error("invalid XPS data: {0}")] InvalidXps(String), + + #[error("invalid Varian/Agilent VnmrJ data: {0}")] + InvalidVarian(String), + + #[error("unsupported Varian/Agilent VnmrJ data: {0}")] + UnsupportedVarian(String), } /// Load a dataset, auto-detecting the format from the path. A Bruker @@ -637,6 +646,9 @@ pub fn detect_format(path: impl AsRef) -> Result { if bruker::is_bruker(path) { return Ok(DataFormat::BrukerRaw); } + if varian::is_varian(path) { + return Ok(DataFormat::VarianAgilentRaw); + } let ext = path .extension() .and_then(|e| e.to_str()) @@ -661,7 +673,7 @@ pub fn detect_format(path: impl AsRef) -> Result { _ if abf2::is_abf2(path) => Ok(DataFormat::Abf2), _ if jeol::is_jdf(path) => Ok(DataFormat::JeolDelta), _ => Err(IoError::Unsupported(format!( - "unrecognised path {}: expected mzML, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser, or Bruker pdata", + "unrecognised path {}: expected mzML, Rigaku FI .raw/.rasx/profile .txt, a Waters .raw directory, NanoScope .spm/.pfc, ABF2 .abf, JEOL .jdf, JCAMP-DX .dx/.jdx/.jcamp, Bruker fid/ser or pdata, or a Varian/Agilent VnmrJ .fid directory", path.display() ))), } @@ -683,6 +695,7 @@ pub fn load_path(path: impl AsRef) -> Result { warnings: Vec::new(), }), DataFormat::BrukerRaw => bruker::load_raw(path), + DataFormat::VarianAgilentRaw => varian::load_raw(path), DataFormat::BrukerProcessed1D | DataFormat::BrukerProcessed2D => { bruker::load_processed(path) } diff --git a/crates/io/src/varian.rs b/crates/io/src/varian.rs new file mode 100644 index 0000000..f2bd660 --- /dev/null +++ b/crates/io/src/varian.rs @@ -0,0 +1,267 @@ +//! Varian and Agilent VNMR/VnmrJ raw acquisition reader. + +mod fid; +mod procpar; + +use crate::{ + Acquisition, DataFormat, Dim, Domain, IoError, LoadResult, NmrData, NmrData2D, Provenance, + QuadMode, +}; +use procpar::Procpar; +use std::path::{Path, PathBuf}; + +pub fn is_varian(path: &Path) -> bool { + resolve(path).is_some_and(|(_, fid, procpar)| fid.is_file() && procpar.is_file()) +} + +fn resolve(path: &Path) -> Option<(PathBuf, PathBuf, PathBuf)> { + let dir = if path.is_dir() { + path.to_path_buf() + } else if path.file_name()?.to_str()? == "fid" { + path.parent()?.to_path_buf() + } else { + return None; + }; + Some((dir.clone(), dir.join("fid"), dir.join("procpar"))) +} + +pub fn load_raw(path: &Path) -> Result { + let (dir, data_path, procpar_path) = resolve(path) + .ok_or_else(|| IoError::InvalidVarian("select a .fid directory or its fid file".into()))?; + if !data_path.is_file() || !procpar_path.is_file() { + return Err(IoError::InvalidVarian( + "a VnmrJ dataset requires sibling fid and procpar files".into(), + )); + } + let params = Procpar::parse(&std::fs::read_to_string(&procpar_path)?)?; + let raw = fid::parse(&std::fs::read(&data_path)?)?; + reject_unsupported(¶ms)?; + let acquisition = assemble(&dir, ¶ms, raw)?; + Ok(LoadResult { + acquisition, + format: DataFormat::VarianAgilentRaw, + provenance: Provenance { + selected_path: path.to_path_buf(), + data_path, + parameter_paths: vec![procpar_path], + companion_paths: Vec::new(), + }, + warnings: Vec::new(), + }) +} + +fn reject_unsupported(p: &Procpar) -> Result<(), IoError> { + if p.number("ni2").unwrap_or(0.0) > 1.0 || p.number("ni3").unwrap_or(0.0) > 1.0 { + return Err(IoError::UnsupportedVarian( + "3D and 4D acquisitions are not supported".into(), + )); + } + if p.string("apptype") + .is_some_and(|s| s.to_ascii_lowercase().contains("imaging")) + { + return Err(IoError::UnsupportedVarian( + "MRI and imaging acquisitions are not supported".into(), + )); + } + if ["sampling", "nus", "nuslist"].iter().any(|name| { + p.string(name) + .is_some_and(|s| !s.is_empty() && !s.eq_ignore_ascii_case("n")) + }) { + return Err(IoError::UnsupportedVarian( + "non-uniform sampling is not supported".into(), + )); + } + Ok(()) +} + +fn assemble(dir: &Path, p: &Procpar, raw: fid::FidData) -> Result { + let procpar_np = exact_positive_usize(p.number("np")) + .ok_or_else(|| IoError::InvalidVarian("procpar is missing positive integer np".into()))?; + if procpar_np != raw.np { + return Err(IoError::InvalidVarian(format!( + "dimension mismatch: procpar np is {procpar_np}, but the fid header declares {}", + raw.np + ))); + } + let direct = direct_dim(p)?; + let total = raw.traces.len(); + let ni = exact_positive_usize(p.number("ni")); + let (phase_count, quad) = phase_layout(p)?; + let array = p.string("array").unwrap_or("").trim(); + if ni.unwrap_or(1) == 1 && total == 1 && array.is_empty() { + let source = description(dir, p, &direct, None); + return Ok(Acquisition::D1(NmrData { + points: raw.traces.into_iter().next().unwrap(), + domain: Domain::Time, + spectral_width_hz: direct.spectral_width_hz, + observe_freq_mhz: direct.observe_freq_mhz, + carrier_ppm: direct.carrier_ppm, + nucleus: direct.nucleus, + source, + group_delay: 0.0, + })); + } + let ni = ni.ok_or_else(|| { + IoError::UnsupportedVarian("multiple traces require a positive integer ni".into()) + })?; + if !array.is_empty() && array != "phase" { + return Err(IoError::UnsupportedVarian(format!( + "parameter arrays other than phase are not supported (array={array})" + ))); + } + let expected = ni + .checked_mul(phase_count) + .ok_or_else(|| IoError::InvalidVarian("2D trace count overflow".into()))?; + if total != expected { + return Err(IoError::UnsupportedVarian(format!( + "trace layout mismatch: fid contains {total} traces, but ni × phase_count is {expected}" + ))); + } + let seq = p + .string("seqfil") + .map(|s| s.to_ascii_lowercase()) + .filter(|s| !s.is_empty()); + let indirect = indirect_dim(p, seq.as_deref(), &direct)?; + let cols = raw.np / 2; + let source = description(dir, p, &direct, Some(&indirect)); + Ok(Acquisition::D2(Box::new(NmrData2D { + data: raw.traces.into_iter().flatten().collect(), + rows: total, + cols, + domain: Domain::Time, + direct, + indirect, + quad, + indirect_conjugate: false, + experiment: seq, + pseudo_axis: None, + diffusion: None, + nus: None, + source, + }))) +} + +fn phase_layout(p: &Procpar) -> Result<(usize, QuadMode), IoError> { + match p.numbers("phase").as_deref() { + None | Some([1.0]) => Ok((1, QuadMode::Complex)), + Some([1.0, 2.0]) => Ok((2, QuadMode::States)), + Some(values) => Err(IoError::UnsupportedVarian(format!( + "unsupported phase table {values:?}; only phase=1 and States phase=1,2 are supported" + ))), + } +} + +fn direct_dim(p: &Procpar) -> Result { + dim(p, "sw", "sfrq", "tof", "tn") +} +fn indirect_dim(p: &Procpar, seq: Option<&str>, direct: &Dim) -> Result { + let homo = seq.is_some_and(|s| { + ["cosy", "tocsy", "noesy", "roesy"] + .iter() + .any(|name| s.contains(name)) + }); + let hetero = seq.is_some_and(|s| ["hsqc", "hmqc", "hmbc"].iter().any(|name| s.contains(name))); + if homo { + return dim_with_sw1(p, direct); + } + if hetero { + return dim(p, "sw1", "dfrq", "dof", "dn"); + } + let tn = normalize_nucleus(p.string("tn").unwrap_or("X")); + let dn = normalize_nucleus(p.string("dn").unwrap_or("X")); + if dn != "X" && dn != tn { + dim(p, "sw1", "dfrq", "dof", "dn") + } else if dn == "X" || dn == tn { + dim_with_sw1(p, direct) + } else { + Err(IoError::UnsupportedVarian( + "unknown sequence has ambiguous indirect channel".into(), + )) + } +} +fn dim_with_sw1(p: &Procpar, direct: &Dim) -> Result { + Ok(Dim { + spectral_width_hz: required_positive(p, "sw1")?, + observe_freq_mhz: direct.observe_freq_mhz, + carrier_ppm: direct.carrier_ppm, + nucleus: direct.nucleus.clone(), + group_delay: 0.0, + }) +} +fn dim(p: &Procpar, sw: &str, freq: &str, offset: &str, nucleus: &str) -> Result { + let spectral_width_hz = required_positive(p, sw)?; + let observe_freq_mhz = required_positive(p, freq)?; + let carrier_ppm = p + .number(offset) + .filter(|v| v.is_finite()) + .ok_or_else(|| IoError::InvalidVarian(format!("procpar is missing finite {offset}")))? + / observe_freq_mhz; + Ok(Dim { + spectral_width_hz, + observe_freq_mhz, + carrier_ppm, + nucleus: normalize_nucleus(p.string(nucleus).unwrap_or("X")), + group_delay: 0.0, + }) +} +fn required_positive(p: &Procpar, name: &str) -> Result { + p.number(name) + .filter(|v| v.is_finite() && *v > 0.0) + .ok_or_else(|| IoError::InvalidVarian(format!("procpar is missing positive finite {name}"))) +} +fn exact_positive_usize(v: Option) -> Option { + let v = v?; + if v.is_finite() && v > 0.0 && v.fract() == 0.0 && v <= usize::MAX as f64 { + Some(v as usize) + } else { + None + } +} +fn normalize_nucleus(value: &str) -> String { + let s = value.trim().trim_matches('"').replace(' ', ""); + let upper = s.to_ascii_uppercase(); + match upper.as_str() { + "H1" | "1H" | "PROTON" => "1H".into(), + "C13" | "13C" => "13C".into(), + "N15" | "15N" => "15N".into(), + "F19" | "19F" => "19F".into(), + "P31" | "31P" => "31P".into(), + "" | "OFF" | "NONE" => "X".into(), + _ => s, + } +} +fn description(dir: &Path, p: &Procpar, direct: &Dim, indirect: Option<&Dim>) -> String { + let nuclei = match indirect { + Some(indirect) => format!("{}/{}", direct.nucleus, indirect.nucleus), + None => direct.nucleus.clone(), + }; + let data_name = ["samplename", "sample", "name", "filename"] + .into_iter() + .find_map(|name| p.string(name).and_then(nonempty)) + .map(str::to_owned) + .or_else(|| { + dir.file_stem() + .and_then(|name| name.to_str()) + .and_then(nonempty) + .map(str::to_owned) + }); + let experiment = ["pslabel", "seqfil"] + .into_iter() + .find_map(|name| p.string(name).and_then(nonempty)) + .map(str::to_owned); + data_name + .into_iter() + .chain(std::iter::once(nuclei)) + .chain(experiment) + .collect::>() + .join(" — ") +} + +fn nonempty(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +#[path = "varian/tests.rs"] +mod tests; diff --git a/crates/io/src/varian/fid.rs b/crates/io/src/varian/fid.rs new file mode 100644 index 0000000..b78bbde --- /dev/null +++ b/crates/io/src/varian/fid.rs @@ -0,0 +1,200 @@ +use crate::IoError; +use num_complex::Complex64; + +const FILE_HEADER: usize = 32; +const BLOCK_HEADER: usize = 28; +const S_DATA: i16 = 0x1; +const S_SPEC: i16 = 0x2; +const S_32: i16 = 0x4; +const S_FLOAT: i16 = 0x8; +const S_COMPLEX: i16 = 0x10; +const S_HYPERCOMPLEX: i16 = 0x20; +const S_DDR: i16 = 0x80; +const S_SECND: i16 = 0x100; +const S_TRANSF: i16 = 0x200; +const S_3D: i16 = 0x400; +const SAMPLE_STATUS: i16 = S_32 | S_FLOAT; +const NB_HEADER_MASK: i32 = 0x0000f; +const NB_NI3: i32 = 0x10000; +const VERSION_FILE_ID_MASK: i16 = 0x07c0; +const VERSION_FID_FILE: i16 = 0x0040; + +#[derive(Debug)] +pub(super) struct FidData { + pub(super) traces: Vec>, + pub(super) np: usize, +} + +pub(super) fn parse(bytes: &[u8]) -> Result { + if bytes.len() < FILE_HEADER { + return truncated(0, FILE_HEADER, bytes.len()); + } + let nblocks = positive_i32(bytes, 0, "nblocks")?; + let ntraces = positive_i32(bytes, 4, "ntraces")?; + let np = positive_i32(bytes, 8, "np")?; + let ebytes = positive_i32(bytes, 12, "ebytes")?; + let tbytes = positive_i32(bytes, 16, "tbytes")?; + let bbytes = positive_i32(bytes, 20, "bbytes")?; + let version_id = i16::from_be_bytes(bytes[24..26].try_into().unwrap()); + let status = i16::from_be_bytes(bytes[26..28].try_into().unwrap()); + let raw_nbheaders = i32::from_be_bytes(bytes[28..32].try_into().unwrap()); + if raw_nbheaders & NB_NI3 != 0 { + return Err(unsupported( + "3D and 4D block-header layouts are not supported", + )); + } + if raw_nbheaders & !(NB_NI3 | NB_HEADER_MASK) != 0 { + return Err(invalid("nbheaders contains unknown layout flags")); + } + let nbheaders = usize::try_from(raw_nbheaders & NB_HEADER_MASK) + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| invalid("nbheaders must declare at least one block header"))?; + if np % 2 != 0 { + return Err(invalid("np must be a positive even number")); + } + if status & S_DATA == 0 || !is_complex_fid(status) { + return Err(unsupported("fid is not complex time-domain data")); + } + if status & (S_SPEC | S_HYPERCOMPLEX) != 0 { + return Err(unsupported( + "processed spectra and hypercomplex payloads are not supported", + )); + } + if status & (S_SECND | S_TRANSF | S_3D) != 0 { + return Err(unsupported( + "transformed, transposed, and 3D payloads are not supported", + )); + } + let file_id = version_id & VERSION_FILE_ID_MASK; + if file_id != 0 && file_id != VERSION_FID_FILE { + return Err(unsupported( + "the software-version header identifies a processed data file", + )); + } + let sample = match (status & S_FLOAT != 0, status & S_32 != 0, ebytes) { + (false, false, 2) => Sample::I16, + (false, true, 4) => Sample::I32, + (true, _, 4) => Sample::F32, + _ => { + return Err(unsupported( + "status flags and ebytes do not describe int16, int32, or float32 samples", + )); + } + }; + let expected_tbytes = np + .checked_mul(ebytes) + .ok_or_else(|| invalid("trace size overflow"))?; + if tbytes != expected_tbytes { + return Err(invalid("tbytes does not equal np * ebytes")); + } + let headers_bytes = nbheaders + .checked_mul(BLOCK_HEADER) + .ok_or_else(|| invalid("block header size overflow"))?; + let trace_bytes = ntraces + .checked_mul(tbytes) + .ok_or_else(|| invalid("block trace size overflow"))?; + let minimum_bbytes = headers_bytes + .checked_add(trace_bytes) + .ok_or_else(|| invalid("block size overflow"))?; + if bbytes < minimum_bbytes { + return Err(invalid("bbytes is smaller than its headers and traces")); + } + let declared = nblocks + .checked_mul(bbytes) + .and_then(|n| FILE_HEADER.checked_add(n)) + .ok_or_else(|| invalid("file size overflow"))?; + if bytes.len() < declared { + return truncated(0, declared, bytes.len()); + } + if bytes.len() != declared { + return Err(invalid( + "file length does not match the declared block layout", + )); + } + + let total = nblocks + .checked_mul(ntraces) + .ok_or_else(|| invalid("trace count overflow"))?; + let mut traces = Vec::with_capacity(total); + for block in 0..nblocks { + let block_at = FILE_HEADER + block * bbytes; + let scale = i16::from_be_bytes(bytes[block_at..block_at + 2].try_into().unwrap()); + let block_status = + i16::from_be_bytes(bytes[block_at + 2..block_at + 4].try_into().unwrap()); + if block_status & S_DATA == 0 + || (block_status & S_COMPLEX == 0 && status & S_DDR == 0) + || block_status & S_SPEC != 0 + { + return Err(invalid( + "block header status is inconsistent with complex time-domain data", + )); + } + if block_status & S_HYPERCOMPLEX != 0 { + return Err(unsupported("hypercomplex block payloads are not supported")); + } + if block_status & SAMPLE_STATUS != status & SAMPLE_STATUS { + return Err(invalid( + "block sample type flags disagree with the file header", + )); + } + let factor = 2.0_f64.powi(i32::from(scale)); + if !factor.is_finite() { + return Err(invalid("block scale is out of range")); + } + for trace in 0..ntraces { + let at = block_at + headers_bytes + trace * tbytes; + let mut points = Vec::with_capacity(np / 2); + for pair in 0..np / 2 { + let real_at = at + pair * 2 * ebytes; + points.push(Complex64::new( + sample.read(bytes, real_at) * factor, + sample.read(bytes, real_at + ebytes) * factor, + )); + } + traces.push(points); + } + } + Ok(FidData { traces, np }) +} + +fn is_complex_fid(status: i16) -> bool { + status & (S_COMPLEX | S_DDR) != 0 +} + +#[derive(Clone, Copy)] +enum Sample { + I16, + I32, + F32, +} +impl Sample { + fn read(self, b: &[u8], at: usize) -> f64 { + match self { + Self::I16 => i16::from_be_bytes(b[at..at + 2].try_into().unwrap()) as f64, + Self::I32 => i32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as f64, + Self::F32 => f32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as f64, + } + } +} + +fn positive_i32(bytes: &[u8], at: usize, name: &str) -> Result { + let value = i32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()); + usize::try_from(value) + .ok() + .filter(|v| *v > 0) + .ok_or_else(|| invalid(format!("{name} must be positive"))) +} +fn invalid(message: impl Into) -> IoError { + IoError::InvalidVarian(message.into()) +} +fn unsupported(message: impl Into) -> IoError { + IoError::UnsupportedVarian(message.into()) +} +fn truncated(offset: usize, needed: usize, have: usize) -> Result { + Err(IoError::Truncated { + offset, + needed, + have, + }) +} diff --git a/crates/io/src/varian/procpar.rs b/crates/io/src/varian/procpar.rs new file mode 100644 index 0000000..4cb47eb --- /dev/null +++ b/crates/io/src/varian/procpar.rs @@ -0,0 +1,187 @@ +use crate::IoError; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub(super) enum Value { + Number(f64), + Text(String), +} + +#[derive(Debug, Default)] +pub(super) struct Procpar { + values: HashMap>, +} + +impl Procpar { + pub(super) fn parse(text: &str) -> Result { + let mut lines = text.lines().enumerate().peekable(); + let mut values = HashMap::new(); + while let Some((line_no, header)) = lines.next() { + if header.trim().is_empty() { + continue; + } + let fields = tokens(header).map_err(|e| invalid(line_no, e))?; + if fields.len() < 3 { + return Err(invalid( + line_no, + "parameter header has fewer than three fields", + )); + } + let name = fields[0].clone(); + let basic_type: i32 = fields[2] + .parse() + .map_err(|_| invalid(line_no, "invalid basic type"))?; + if basic_type != 1 && basic_type != 2 { + return Err(invalid(line_no, "unsupported basic type")); + } + let (value_line_no, first) = lines + .next() + .ok_or_else(|| invalid(line_no, "missing value record"))?; + let mut value_tokens = tokens(first).map_err(|e| invalid(value_line_no, e))?; + let count = parse_count(&mut value_tokens, value_line_no, "value")?; + while value_tokens.len() < count { + let (continuation_no, continuation) = lines + .next() + .ok_or_else(|| invalid(value_line_no, "truncated value record"))?; + value_tokens.extend(tokens(continuation).map_err(|e| invalid(continuation_no, e))?); + } + if value_tokens.len() != count { + return Err(invalid(value_line_no, "value count does not match record")); + } + let parsed = value_tokens + .into_iter() + .map(|token| { + if basic_type == 1 { + token.parse::().map(Value::Number).map_err(|_| { + invalid( + value_line_no, + "numeric parameter contains non-numeric value", + ) + }) + } else { + Ok(Value::Text(token)) + } + }) + .collect::, _>>()?; + + let (enum_line_no, enum_first) = lines + .next() + .ok_or_else(|| invalid(value_line_no, "missing enumeration record"))?; + let mut enum_tokens = tokens(enum_first).map_err(|e| invalid(enum_line_no, e))?; + let enum_count = parse_count(&mut enum_tokens, enum_line_no, "enumeration")?; + while enum_tokens.len() < enum_count { + let (continuation_no, continuation) = lines + .next() + .ok_or_else(|| invalid(enum_line_no, "truncated enumeration record"))?; + enum_tokens.extend(tokens(continuation).map_err(|e| invalid(continuation_no, e))?); + } + if enum_tokens.len() != enum_count { + return Err(invalid( + enum_line_no, + "enumeration count does not match record", + )); + } + values.insert(name, parsed); + } + Ok(Self { values }) + } + + pub(super) fn numbers(&self, name: &str) -> Option> { + self.values + .get(name)? + .iter() + .map(|v| match v { + Value::Number(n) => Some(*n), + Value::Text(_) => None, + }) + .collect() + } + + pub(super) fn number(&self, name: &str) -> Option { + self.numbers(name)?.first().copied() + } + + pub(super) fn strings(&self, name: &str) -> Option> { + self.values + .get(name)? + .iter() + .map(|v| match v { + Value::Text(s) => Some(s.as_str()), + Value::Number(_) => None, + }) + .collect() + } + + pub(super) fn string(&self, name: &str) -> Option<&str> { + self.strings(name)?.first().copied() + } +} + +fn parse_count(tokens: &mut Vec, line: usize, kind: &str) -> Result { + if tokens.is_empty() { + return Err(invalid(line, format!("missing {kind} count"))); + } + let count = tokens + .remove(0) + .parse::() + .map_err(|_| invalid(line, format!("invalid {kind} count")))?; + Ok(count) +} + +fn invalid(line: usize, message: impl Into) -> IoError { + IoError::InvalidVarian(format!("procpar line {}: {}", line + 1, message.into())) +} + +fn tokens(line: &str) -> Result, &'static str> { + let mut out = Vec::new(); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + continue; + } + if c == '"' { + let mut value = String::new(); + let mut closed = false; + while let Some(c) = chars.next() { + if c == '"' { + closed = true; + break; + } + if c == '\\' { + value.push(chars.next().ok_or("unterminated escape in quoted string")?); + } else { + value.push(c); + } + } + if !closed { + return Err("unterminated quoted string"); + } + out.push(value); + } else { + let mut value = String::from(c); + while chars.peek().is_some_and(|c| !c.is_whitespace()) { + value.push(chars.next().unwrap()); + } + out.push(value); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_numbers_strings_arrays_and_enums() { + let p = Procpar::parse("sw 1 1 0 0 0 0 0 0 1 0\n2 1000 2000\n1 5000\ncomment 1 2 0 0 0 0 0 0 1 0\n1 \"a value with spaces\"\n2 \"yes\" \"no\"\n").unwrap(); + assert_eq!(p.numbers("sw"), Some(vec![1000.0, 2000.0])); + assert_eq!(p.string("comment"), Some("a value with spaces")); + } + + #[test] + fn rejects_bad_counts_and_quotes() { + assert!(Procpar::parse("x 1 1\n2 1\n0\n").is_err()); + assert!(Procpar::parse("x 1 2\n1 \"oops\n0\n").is_err()); + } +} diff --git a/crates/io/src/varian/tests.rs b/crates/io/src/varian/tests.rs new file mode 100644 index 0000000..b7e12da --- /dev/null +++ b/crates/io/src/varian/tests.rs @@ -0,0 +1,279 @@ +use super::*; +use num_complex::Complex64; +use std::sync::atomic::{AtomicU64, Ordering}; + +fn record(name: &str, basic: i32, values: &str) -> String { + format!("{name} 1 {basic}\n{values}\n0\n") +} + +fn base_procpar() -> String { + [ + record("np", 1, "1 4"), + record("sw", 1, "1 4000"), + record("sfrq", 1, "1 500"), + record("tof", 1, "1 2500"), + record("tn", 2, "1 \"H1\""), + record("array", 2, "1 \"\""), + ] + .concat() +} + +#[derive(Clone, Copy)] +enum Encoding { + I16, + I32, + F32, +} + +fn fid_bytes(blocks: &[Vec>], encoding: Encoding, scales: &[i16]) -> Vec { + let nblocks = blocks.len(); + let ntraces = blocks[0].len(); + let np = blocks[0][0].len(); + let ebytes = match encoding { + Encoding::I16 => 2, + Encoding::I32 | Encoding::F32 => 4, + }; + let tbytes = np * ebytes; + let bbytes = 28 + ntraces * tbytes; + let status = 0x11 + | match encoding { + Encoding::I16 => 0, + Encoding::I32 => 0x4, + Encoding::F32 => 0xc, + }; + let mut out = Vec::new(); + for value in [nblocks, ntraces, np, ebytes, tbytes, bbytes] { + out.extend_from_slice(&(value as i32).to_be_bytes()); + } + out.extend_from_slice(&0_i16.to_be_bytes()); + out.extend_from_slice(&(status as i16).to_be_bytes()); + out.extend_from_slice(&1_i32.to_be_bytes()); + for (block, &scale) in blocks.iter().zip(scales) { + out.extend_from_slice(&scale.to_be_bytes()); + out.extend_from_slice(&(status as i16).to_be_bytes()); + out.extend_from_slice(&[0; 24]); + for trace in block { + for value in trace { + match encoding { + Encoding::I16 => out.extend_from_slice(&(*value as i16).to_be_bytes()), + Encoding::I32 => out.extend_from_slice(&(*value as i32).to_be_bytes()), + Encoding::F32 => out.extend_from_slice(&(*value as f32).to_be_bytes()), + } + } + } + } + out +} + +fn dataset(procpar: &str, fid: &[u8]) -> std::path::PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "plotx_varian_{}_{}.fid", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("procpar"), procpar).unwrap(); + std::fs::write(dir.join("fid"), fid).unwrap(); + dir +} + +#[test] +fn loads_directory_and_fid_with_provenance_and_metadata() { + let mut procpar = base_procpar(); + procpar.push_str(&record("samplename", 2, "1 \"Test sample\"")); + procpar.push_str(&record("pslabel", 2, "1 \"PROTON\"")); + let dir = dataset( + &procpar, + &fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[1]), + ); + assert_eq!( + crate::detect_format(&dir).unwrap(), + DataFormat::VarianAgilentRaw + ); + for selected in [&dir, &dir.join("fid")] { + let loaded = load_raw(selected).unwrap(); + assert_eq!(loaded.provenance.selected_path, *selected); + assert_eq!(loaded.provenance.data_path, dir.join("fid")); + assert_eq!(loaded.provenance.parameter_paths, vec![dir.join("procpar")]); + let Acquisition::D1(data) = loaded.acquisition else { + panic!("expected 1D") + }; + assert_eq!( + data.points, + vec![Complex64::new(2., 4.), Complex64::new(6., 8.)] + ); + assert_eq!( + ( + data.spectral_width_hz, + data.observe_freq_mhz, + data.carrier_ppm + ), + (4000., 500., 5.) + ); + assert_eq!(data.nucleus, "1H"); + assert_eq!(data.source, "Test sample — 1H — PROTON"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn reads_all_sample_widths_and_block_major_trace_minor_order() { + for encoding in [Encoding::I16, Encoding::I32, Encoding::F32] { + let bytes = fid_bytes( + &[ + vec![vec![1., 2.], vec![3., 4.]], + vec![vec![5., 6.], vec![7., 8.]], + ], + encoding, + &[0, 1], + ); + let raw = fid::parse(&bytes).unwrap(); + assert_eq!( + raw.traces.iter().flatten().copied().collect::>(), + vec![ + Complex64::new(1., 2.), + Complex64::new(3., 4.), + Complex64::new(10., 12.), + Complex64::new(14., 16.) + ] + ); + } +} + +#[test] +fn loads_homonuclear_and_heteronuclear_states_2d() { + let raw = fid_bytes( + &[ + vec![vec![1., 2., 3., 4.], vec![5., 6., 7., 8.]], + vec![vec![9., 10., 11., 12.], vec![13., 14., 15., 16.]], + ], + Encoding::I32, + &[0, 0], + ); + for (seq, indirect) in [("gcosy", (500., 5., "1H")), ("ghsqc", (125., 80., "13C"))] { + let mut p = base_procpar(); + p.push_str(&record("ni", 1, "1 2")); + p.push_str(&record("phase", 1, "2 1 2")); + p.push_str(&record("array", 2, "1 \"phase\"")); + p.push_str(&record("sw1", 1, "1 20000")); + p.push_str(&record("seqfil", 2, &format!("1 \"{seq}\""))); + if seq == "ghsqc" { + p.push_str(&record("dfrq", 1, "1 125")); + p.push_str(&record("dof", 1, "1 10000")); + p.push_str(&record("dn", 2, "1 \"C13\"")); + } + let dir = dataset(&p, &raw); + let Acquisition::D2(data) = load_raw(&dir).unwrap().acquisition else { + panic!("expected 2D") + }; + assert_eq!((data.rows, data.cols, data.quad), (4, 2, QuadMode::States)); + assert_eq!( + ( + data.indirect.observe_freq_mhz, + data.indirect.carrier_ppm, + data.indirect.nucleus.as_str() + ), + indirect + ); + assert_eq!(data.experiment.as_deref(), Some(seq)); + assert!(!data.indirect_conjugate); + std::fs::remove_dir_all(dir).unwrap(); + } +} + +#[test] +fn rejects_unsupported_two_entry_phase_table() { + let raw = fid_bytes( + &[ + vec![vec![1., 2., 3., 4.], vec![5., 6., 7., 8.]], + vec![vec![9., 10., 11., 12.], vec![13., 14., 15., 16.]], + ], + Encoding::I32, + &[0, 0], + ); + let mut p = base_procpar(); + p.push_str(&record("ni", 1, "1 2")); + p.push_str(&record("phase", 1, "2 1 3")); + p.push_str(&record("array", 2, "1 \"phase\"")); + let dir = dataset(&p, &raw); + + assert!(matches!(load_raw(&dir), Err(IoError::UnsupportedVarian(_)))); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn rejects_procpar_np_disagreement() { + let mut p = base_procpar(); + p.push_str(&record("np", 1, "1 6")); + let dir = dataset( + &p, + &fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[0]), + ); + + assert!(matches!(load_raw(&dir), Err(IoError::InvalidVarian(_)))); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn rejects_corrupt_and_unsupported_layouts() { + let mut odd = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I16, &[0]); + odd[8..12].copy_from_slice(&3_i32.to_be_bytes()); + assert!(matches!(fid::parse(&odd), Err(IoError::InvalidVarian(_)))); + let mut truncated = fid_bytes(&[vec![vec![1., 2.]]], Encoding::I16, &[0]); + truncated.pop(); + assert!(matches!( + fid::parse(&truncated), + Err(IoError::Truncated { .. }) + )); + let mut spectrum = fid_bytes(&[vec![vec![1., 2.]]], Encoding::I16, &[0]); + spectrum[26..28].copy_from_slice(&0x13_i16.to_be_bytes()); + assert!(matches!( + fid::parse(&spectrum), + Err(IoError::UnsupportedVarian(_)) + )); +} + +#[test] +fn accepts_ddr_fid_without_legacy_complex_bit() { + let mut ddr = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::F32, &[0]); + let status = 0x00c9_i16; + ddr[26..28].copy_from_slice(&status.to_be_bytes()); + ddr[34..36].copy_from_slice(&status.to_be_bytes()); + + let raw = fid::parse(&ddr).unwrap(); + assert_eq!( + raw.traces[0], + vec![Complex64::new(1., 2.), Complex64::new(3., 4.)] + ); +} + +#[test] +fn rejects_processed_and_higher_dimensional_header_flags() { + let base = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::F32, &[0]); + for status_bit in [0x2_i16, 0x100, 0x200, 0x400] { + let mut bytes = base.clone(); + let status = i16::from_be_bytes(bytes[26..28].try_into().unwrap()) | status_bit; + bytes[26..28].copy_from_slice(&status.to_be_bytes()); + assert!(matches!( + fid::parse(&bytes), + Err(IoError::UnsupportedVarian(_)) + )); + } + + let mut ni3 = base.clone(); + ni3[28..32].copy_from_slice(&0x10001_i32.to_be_bytes()); + assert!(matches!( + fid::parse(&ni3), + Err(IoError::UnsupportedVarian(_)) + )); +} + +#[test] +fn rejects_block_sample_type_disagreement() { + let mut bytes = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::F32, &[0]); + let integer = fid_bytes(&[vec![vec![1., 2., 3., 4.]]], Encoding::I32, &[0]); + bytes[34..36].copy_from_slice(&integer[34..36]); + assert!(matches!(fid::parse(&bytes), Err(IoError::InvalidVarian(_)))); +} diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index 836595c..9e2c5b8 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -12,6 +12,7 @@ no conversion step is needed. | --- | --- | --- | | JEOL Delta | `.jdf` | 1D, 2D, and pseudo-2D (DOSY / T1 / T2) | | Bruker TopSpin | `fid` / `ser` directories | 1D and 2D | +| Varian/Agilent VnmrJ | `.fid` directory | Raw time-domain 1D and conventional 2D | | Waters MassLynx RAW | `.raw` directory | Validated low-resolution runs, including SQD2 data | | Rigaku powder XRD | `.rasx`, FI `.raw`, RAS_RAW `.txt` | Diffraction pattern, acquisition metadata, and attenuation when available | | mzML | `.mzML` | Centroided or profile LC–MS spectra with 32-bit or 64-bit arrays, uncompressed or zlib-compressed | @@ -29,7 +30,8 @@ no conversion step is needed. Drag a file onto the PlotX window, or use the toolbar's open menu: *Open File…*, *Open Folder…* (for acquisition directories such as Bruker -TopSpin and Waters MassLynx RAW), *Open Project…*, or *Import Table…*. +TopSpin, Varian/Agilent VnmrJ, and Waters MassLynx RAW), *Open Project…*, or +*Import Table…*. Each imported dataset appears in the Primary Side Bar and is placed on the board automatically. The file picker accepts several ABF files at once. Opening a folder recursively @@ -41,6 +43,17 @@ CasaXPS `.txt` files are recognized from their structured header, not from the extension alone. Other `.txt` files continue through table import. See the [XPS workflow](/guides/xps/) for energy-axis and fitting details. +## Varian/Agilent VnmrJ + +To import a raw 1D or conventional 2D acquisition, choose **Open Folder…** and +select its `.fid` directory. You can instead choose **Open File…** and select +the `fid` file inside. Keep the `fid` and `procpar` files together in the same +directory. + +Processed spectra, 3D or 4D experiments, imaging, pseudo-2D experiments, +non-uniform sampling, and other arrayed experiments are not supported. See +[File formats](/reference/file-formats/) for compatibility details. + ## mzML Open or drop a `.mzML` file. PlotX imports the spectra into the same LC–MS diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index e6691e2..340c7f3 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -59,6 +59,20 @@ A `.plotxproc` file stores one processing pipeline, without any data — save a recipe once and apply it to a whole series of similar experiments, on any machine. See [Recipes and templates](/guides/templates/). +## Varian/Agilent VnmrJ raw NMR + +PlotX imports raw time-domain 1D and conventional 2D acquisitions. Select the +`.fid` directory or the `fid` file inside it; the `fid` and `procpar` files must +both be present in that directory. A `.fid` directory name by itself is not +enough to identify a dataset. + +The importer accepts the common 16-bit integer, 32-bit integer, and 32-bit +floating-point sample formats, including conventional States 2D data. +Processed spectra, 3D or 4D experiments, imaging, pseudo-2D experiments, +non-uniform sampling, and arrayed parameters other than phase are not +supported. The import also stops if the recorded dimensions do not match the +data. + ## Workflow and run-record files An [automation](/guides/automation/) workflow file is a JSON description of a 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 7934785..9e95101 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -11,6 +11,7 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 | --- | --- | --- | | JEOL Delta | `.jdf` | 1D、2D 及伪 2D(DOSY / T1 / T2) | | Bruker TopSpin | `fid` / `ser` 目录 | 1D 与 2D | +| Varian/Agilent VnmrJ | `.fid` 目录 | 原始时域 1D 与常规 2D | | Waters MassLynx RAW | `.raw` 目录 | 已验证的低分辨率数据,包括 SQD2 数据 | | Rigaku 粉末 XRD | `.rasx`、FI `.raw`、RAS_RAW `.txt` | 衍射图样、采集元数据,以及文件提供的衰减系数 | | mzML | `.mzML` | 使用 32 位或 64 位、未压缩或 zlib 压缩数组的质心或轮廓 LC–MS 谱图 | @@ -27,7 +28,7 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 ## 打开文件 把文件拖到 PlotX 窗口上,或使用工具栏的打开菜单:*Open File…*、 -*Open Folder…*(用于 Bruker TopSpin 与 Waters MassLynx RAW 等采集目录)、 +*Open Folder…*(用于 Bruker TopSpin、Varian/Agilent VnmrJ 与 Waters MassLynx RAW 等采集目录)、 *Open Project…* 或 *Import Table…*。每个导入的数据集会出现在主侧栏中, 并自动放置到画板上。 文件选择器可以一次选择多个 ABF。打开文件夹时会递归导入其中所有 `.abf`、 @@ -37,6 +38,15 @@ PlotX 直接读取厂商 LC–MS、NMR、XPS、AFM 与电生理格式,无需 CasaXPS `.txt` 按结构头内容识别,而不是只看扩展名;其他 `.txt` 仍进入表格导入。 能量轴与拟合细节见 [XPS 工作流](/zh-cn/guides/xps/)。 +## Varian/Agilent VnmrJ + +要导入原始 1D 或常规 2D 采集,请选择 **Open Folder…** 并选中 +`.fid` 目录。也可以选择 **Open File…**,再选中目录内的 `fid` +文件。请将 `fid` 和 `procpar` 保持在同一目录中。 + +暂不支持处理后的谱图、3D 或 4D 实验、成像、伪 2D 实验、非均匀采样及 +其他数组实验。兼容性详情见[文件格式](/zh-cn/reference/file-formats/)。 + ## mzML 打开或拖入 `.mzML` 文件。PlotX 会将谱图导入与 Waters 数据相同的 LC–MS diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index 88257ab..4da449a 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -48,6 +48,17 @@ TIFF Pages…** 导入 PlotX 能够读取的所有页面。导入后的各页可 任何机器上应用到一整个系列的同类实验。见 [配方与模板](/zh-cn/guides/templates/)。 +## Varian/Agilent VnmrJ 原始 NMR + +PlotX 可导入原始时域 1D 和常规 2D 采集。请选择 `.fid` 目录或其中的 +`fid` 文件;该目录中必须同时存在 `fid` 和 `procpar`。仅有 `.fid` +目录名不足以识别数据。 + +导入器支持常见的 16 位整数、32 位整数和 32 位浮点样本格式,包括常规 +States 2D 数据。暂不支持处理后的谱图、3D 或 4D 实验、成像、伪 2D 实验、 +非均匀采样,以及除 phase 以外的参数数组。如果文件记录的维度与数据不一致, +导入也会停止。 + ## 工作流与运行记录文件 [自动化](/zh-cn/guides/automation/)工作流文件是一次批处理运行的 JSON